diff options
-rw-r--r-- | .envrc | 1 | ||||
-rw-r--r-- | .gitignore | 3 | ||||
-rw-r--r-- | Cargo.lock | 1868 | ||||
-rw-r--r-- | Cargo.toml | 26 | ||||
-rw-r--r-- | build.rs | 33 | ||||
-rw-r--r-- | data/tc.mal.lleap.gschema.xml | 10 | ||||
-rw-r--r-- | flake.lock | 161 | ||||
-rw-r--r-- | flake.nix | 76 | ||||
-rw-r--r-- | resources/style.css | 19 | ||||
-rw-r--r-- | src/app.rs | 371 | ||||
-rw-r--r-- | src/cue_view.rs | 179 | ||||
-rw-r--r-- | src/main.rs | 45 | ||||
-rw-r--r-- | src/player.rs | 298 | ||||
-rw-r--r-- | src/preferences.rs | 71 | ||||
-rw-r--r-- | src/subtitle_extractor.rs | 209 | ||||
-rw-r--r-- | src/subtitle_view.rs | 94 | ||||
-rw-r--r-- | src/transcript.rs | 143 | ||||
-rw-r--r-- | src/util/mod.rs | 3 | ||||
-rw-r--r-- | src/util/option_tracker.rs | 43 |
19 files changed, 3653 insertions, 0 deletions
diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37ebbba --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +result +test-files diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f5608ef --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1868 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "anyhow" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "atomic_refcell" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e67cd8309bbd06cd603a9e693a784ac2e5d1e955f11286e355089fcab3047c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "cairo-rs" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1158f326d7b755a9ae2b36c5b5391400e3431f3b77418cedb6d7130126628f10" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b963177900ec8e783927e5ed99e16c0ec1b723f1f125dff8992db28ef35c62c3" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cc" +version = "1.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590f9024a68a8c40351881787f1934dc11afd69090f5edb6831464694d836ea3" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-expr" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d458d63f0f0f482c8da9b7c8b76c21bd885a02056cc94c6404d861ca2b8206" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "ffmpeg-next" +version = "7.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da02698288e0275e442a47fc12ca26d50daf0d48b15398ba5906f20ac2e2a9f9" +dependencies = [ + "bitflags", + "ffmpeg-sys-next", + "libc", +] + +[[package]] +name = "ffmpeg-sys-next" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e9c75ebd4463de9d8998fb134ba26347fe5faee62fabf0a4b4d41bd500b4ad" +dependencies = [ + "bindgen", + "cc", + "libc", + "num_cpus", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e178e4fba8a2726903f6ba98a6d221e76f9c12c650d5dc0e6afdc50677b49650" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin", +] + +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7330cdbbc653df431331ae3d9d59e985a0fecaf33d74c7c1c5d13ab0245f6c" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e25899cc931dc28cba912ebec793b730f53d2d419f90a562fcb29b53bd10aa82" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a67b064d2f35e649232455c7724f56f977555d2608c43300eabc530eaa4e359" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2edbda0d879eb85317bdb49a3da591ed70a804a10776e358ef416be38c6db2c5" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "gio" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5e3f390d01b79e30da451dd00e27cd1ac2de81658e3abf6c1fc3229b24c5f" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a03f2234671e5a588cfe1f59c2b22c103f5772ea351be9cc824a9ce0d06d99fd" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.60.2", +] + +[[package]] +name = "glib" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60bdc26493257b5794ba9301f7cbaf7ab0d69a570bfbefa4d7d360e781cb5205" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e772291ebea14c28eb11bb75741f62f4a4894f25e60ce80100797b6b010ef0f9" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc7c43cff6a7dc43821e45ebf172399437acd6716fa2186b6852d2b397bf622d" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e9a190eef2bce144a6aa8434e306974c6062c398e0a33a146d60238f9062d5c" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96914394464c04df8279c23976293afd53b2588e03c9d8d9662ef6528654a85" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8205bb19b7a041cf059be3c94d6b23b3f2c6c96362c44311dcf184e4a9422a" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5dbe33ceed6fc20def67c03d36e532f5a4a569ae437ae015a7146094f31e10c" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d76011d55dd19fde16ffdedee08877ae6ec942818cfa7bc08a91259bc0b9fc9" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gstreamer" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f5db514ad5ccf70ad35485058aa8b894bb81cfcf76bb994af135d9789427c6" +dependencies = [ + "cfg-if", + "futures-channel", + "futures-core", + "futures-util", + "glib", + "gstreamer-sys", + "itertools 0.14.0", + "kstring", + "libc", + "muldiv", + "num-integer", + "num-rational", + "option-operations", + "paste", + "pin-project-lite", + "smallvec", + "thiserror", +] + +[[package]] +name = "gstreamer-base" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34745d3726a080e0d57e402a314e37073d0b341f3a5754258550311ca45e4754" +dependencies = [ + "atomic_refcell", + "cfg-if", + "glib", + "gstreamer", + "gstreamer-base-sys", + "libc", +] + +[[package]] +name = "gstreamer-base-sys" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfad00fa63ddd8132306feef9d5095a3636192f09d925adfd0a9be0d82b9ea91" +dependencies = [ + "glib-sys", + "gobject-sys", + "gstreamer-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gstreamer-play" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3906eae143491efcc688fbd520ce2cfc0b6addf68a14fc0a12e8c92345e19b6" +dependencies = [ + "glib", + "gstreamer", + "gstreamer-play-sys", + "gstreamer-video", + "libc", +] + +[[package]] +name = "gstreamer-play-sys" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c7d544269864b671235410387b851ba6ca63c91b2ed616188df8f7f1233377c" +dependencies = [ + "glib-sys", + "gobject-sys", + "gstreamer-sys", + "gstreamer-video-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gstreamer-sys" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f46b35f9dc4b5a0dca3f19d2118bb5355c3112f228a99a84ed555f48ce5cf9" +dependencies = [ + "cfg-if", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gstreamer-video" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2a0e4dbc6b5563fa252eaeb4297ca04e7dd2e239c68f67eeeb95148f7d31652" +dependencies = [ + "cfg-if", + "futures-channel", + "glib", + "gstreamer", + "gstreamer-base", + "gstreamer-video-sys", + "libc", + "thiserror", +] + +[[package]] +name = "gstreamer-video-sys" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d944b1492bdd7a72a02ae9a5da6e34a29194b8623d3bd02752590b06fb837a7" +dependencies = [ + "glib-sys", + "gobject-sys", + "gstreamer-base-sys", + "gstreamer-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "938d68ad43080ad5ee710c30d467c1bc022ee5947856f593855691d726305b3e" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0912d2068695633002b92c5966edc108b2e4f54b58c509d1eeddd4cbceb7315c" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a923bdcf00e46723801162de24432cbce38a6810e0178a2d0b6dd4ecc26a1c74" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "indexmap" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "isolang" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe50d48c77760c55188549098b9a7f6e37ae980c586a24693d6b01c3b2010c3c" +dependencies = [ + "phf", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "jiff" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kstring" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +dependencies = [ + "static_assertions", +] + +[[package]] +name = "libadwaita" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4df6715d1257bd8c093295b77a276ed129d73543b10304fec5829ced5d5b7c41" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "libc", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf8950090cc180250cdb1ff859a39748feeda7a53a9f28ead3a17a14cc37ae2" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "libloading" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" +dependencies = [ + "cfg-if", + "windows-targets 0.53.3", +] + +[[package]] +name = "lleap" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-channel", + "env_logger", + "ffmpeg-next", + "gstreamer", + "gstreamer-play", + "gstreamer-video", + "gtk4", + "isolang", + "libadwaita", + "log", + "relm4", + "relm4-components", + "tracker", + "unicode-segmentation", +] + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.59.0", +] + +[[package]] +name = "muldiv" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956787520e75e9bd233246045d19f42fb73242759cc57fba9611d940ae96d4b0" + +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "option-operations" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c26d27bb1aeab65138e4bf7666045169d1717febcc9ff870166be8348b223d0" +dependencies = [ + "paste", +] + +[[package]] +name = "pango" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab47feb3403aa564edaeb68620c5b9159f8814733a7dd45f0b1a27d19de362fe" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f855bccb447644e149fae79086e1f81514c30fe5e9b8bd257d9d3c941116c86" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "relm4" +version = "0.10.0-beta.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae511aa143f009ceeb9f8b4d1612a0a920a1edcedc97ef35338217a2f7569d" +dependencies = [ + "flume", + "fragile", + "futures", + "gtk4", + "libadwaita", + "once_cell", + "relm4-css", + "relm4-macros", + "tokio", + "tracing", +] + +[[package]] +name = "relm4-components" +version = "0.10.0-beta.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50fd7b32281a247772c69603c097d6f724f7b8dc7eea8078eb4954b85df8ba57" +dependencies = [ + "once_cell", + "relm4", + "tracker", +] + +[[package]] +name = "relm4-css" +version = "0.10.0-beta.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fd73f61de9f0bb0f501e31256116c82965ceeb9fcfc980820c2d769e2e8547c" + +[[package]] +name = "relm4-macros" +version = "0.10.0-beta.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdf0f016cfabe3dfd7862425dd2cf0048c9829800f55589870d87b238ab91bab" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4be53aa0cba896d2dc615bd42bbc130acdcffa239e0a2d965ea5b3b2a86ffdb" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" + +[[package]] +name = "thiserror" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "io-uring", + "libc", + "mio", + "pin-project-lite", + "slab", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tracker" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5c98457ff700aaeefcd4a4a492096e78a2af1dd8523c66e94a3adb0fdbd415" +dependencies = [ + "tracker-macros", +] + +[[package]] +name = "tracker-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc19eb2373ccf3d1999967c26c3d44534ff71ae5d8b9dacf78f4b13132229e48" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9d2e084 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "lleap" +version = "0.1.0" +edition = "2024" + +[dependencies] +gst = { version = "0.24.1", package = "gstreamer", features = ["v1_26"] } +gst-video = { version = "0.24.1", package = "gstreamer-video", features = ["v1_26"] } +gst-play = { version = "0.24.0", package = "gstreamer-play", features = ["v1_26"] } +gtk = { version = "0.10.0", package = "gtk4", features = ["v4_18"] } +adw = { version = "0.8.0", package = "libadwaita", features = ["v1_7"] } +async-channel = "2.0" +relm4 = { version = "0.10.0-beta.4", features = ["libadwaita"] } +relm4-components = "0.10.0-beta.4" +ffmpeg = { version = "7.1.0", package = "ffmpeg-next" } +anyhow = "1.0" +env_logger = "0.11" +log = "0.4" +tracker = "0.2.2" +unicode-segmentation = "1.12.0" +isolang = "2.4.0" + + +# TODO remove +[profile.release] +debug = true diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..9e7d61e --- /dev/null +++ b/build.rs @@ -0,0 +1,33 @@ +use std::env; +use std::path::Path; +use std::process::Command; + +fn main() { + // Tell cargo to rerun this build script if the schema file changes + println!("cargo:rerun-if-changed=data/tc.mal.lleap.gschema.xml"); + + let out_dir = env::var("OUT_DIR").unwrap(); + let schema_dir = Path::new(&out_dir).join("glib-2.0").join("schemas"); + + // Create the schema directory + std::fs::create_dir_all(&schema_dir).unwrap(); + + // Copy the schema file to the output directory + std::fs::copy( + "data/tc.mal.lleap.gschema.xml", + schema_dir.join("tc.mal.lleap.gschema.xml"), + ) + .unwrap(); + + // Compile the schema using glib-compile-schemas + Command::new("glib-compile-schemas") + .arg(&schema_dir) + .output() + .unwrap(); + + // Set environment variable for the schema directory + println!( + "cargo:rustc-env=GSETTINGS_SCHEMA_DIR={}", + schema_dir.display() + ); +} diff --git a/data/tc.mal.lleap.gschema.xml b/data/tc.mal.lleap.gschema.xml new file mode 100644 index 0000000..0364942 --- /dev/null +++ b/data/tc.mal.lleap.gschema.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<schemalist> + <schema id="tc.mal.lleap" path="/tc/mal/lleap/"> + <key name="deepl-api-key" type="s"> + <default>""</default> + <summary>DeepL API Key</summary> + <description>API key for DeepL translation service</description> + </key> + </schema> +</schemalist> diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..83137df --- /dev/null +++ b/flake.lock @@ -0,0 +1,161 @@ +{ + "nodes": { + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1756795219, + "narHash": "sha256-tKBQtz1JLKWrCJUxVkHKR+YKmVpm0KZdJdPWmR2slQ8=", + "owner": "nix-community", + "repo": "fenix", + "rev": "80dbdab137f2809e3c823ed027e1665ce2502d74", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "fenix_2": { + "inputs": { + "nixpkgs": [ + "naersk", + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src_2" + }, + "locked": { + "lastModified": 1752475459, + "narHash": "sha256-z6QEu4ZFuHiqdOPbYss4/Q8B0BFhacR8ts6jO/F/aOU=", + "owner": "nix-community", + "repo": "fenix", + "rev": "bf0d6f70f4c9a9cf8845f992105652173f4b617f", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "naersk": { + "inputs": { + "fenix": "fenix_2", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1752689277, + "narHash": "sha256-uldUBFkZe/E7qbvxa3mH1ItrWZyT6w1dBKJQF/3ZSsc=", + "owner": "nix-community", + "repo": "naersk", + "rev": "0e72363d0938b0208d6c646d10649164c43f4d64", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "naersk", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1756696532, + "narHash": "sha256-6FWagzm0b7I/IGigOv9pr6LL7NQ86mextfE8g8Q6HBg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "58dcbf1ec551914c3756c267b8b9c8c86baa1b2f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "fenix": "fenix", + "flake-utils": "flake-utils", + "naersk": "naersk", + "nixpkgs": "nixpkgs" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1756597274, + "narHash": "sha256-wfaKRKsEVQDB7pQtAt04vRgFphkVscGRpSx3wG1l50E=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "21614ed2d3279a9aa1f15c88d293e65a98991b30", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "rust-analyzer-src_2": { + "flake": false, + "locked": { + "lastModified": 1752428706, + "narHash": "sha256-EJcdxw3aXfP8Ex1Nm3s0awyH9egQvB2Gu+QEnJn2Sfg=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "591e3b7624be97e4443ea7b5542c191311aa141d", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..18cb4b5 --- /dev/null +++ b/flake.nix @@ -0,0 +1,76 @@ +{ + inputs = { + flake-utils.url = "github:numtide/flake-utils"; + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + fenix = { + url = "github:nix-community/fenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + naersk = { + url = "github:nix-community/naersk"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + outputs = + { + self, + flake-utils, + nixpkgs, + fenix, + naersk, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + fenix' = fenix.packages.${system}; + toolchain = fenix'.stable.withComponents [ + "cargo" + "clippy" + "rust-src" + "rustc" + "rustfmt" + ]; + naersk' = naersk.lib.${system}.override { + cargo = toolchain; + rustc = toolchain; + }; + rustPlatform = pkgs.makeRustPlatform { + cargo = toolchain; + rustc = toolchain; + }; + in + rec { + defaultPackage = naersk'.buildPackage { + src = ./.; + + nativeBuildInputs = with pkgs; [ + pkg-config + rustPlatform.bindgenHook + ]; + + buildInputs = with pkgs; [ + gtk4 + libadwaita + gst_all_1.gstreamer + gst_all_1.gst-plugins-base + gst_all_1.gst-plugins-good + gst_all_1.gst-plugins-bad + gst_all_1.gst-plugins-ugly + gst_all_1.gst-plugins-rs + gst_all_1.gst-libav + gst_all_1.gst-vaapi + ffmpeg_7-full.dev + ]; + }; + + devShell = pkgs.mkShell { + inputsFrom = [ defaultPackage ]; + buildInputs = [ + fenix'.rust-analyzer + rustPlatform.bindgenHook + ]; + }; + } + ); +} diff --git a/resources/style.css b/resources/style.css new file mode 100644 index 0000000..44106e1 --- /dev/null +++ b/resources/style.css @@ -0,0 +1,19 @@ +.cue-view { + font-size: 24px; + background: shade(@theme_bg_color, 0.95); + padding: 12px; + border-radius: 12px; +} + +.cue-view link { + color: @theme_fg_color; + text-decoration: none; +} + +.cue-view link:hover { + background: shade(@theme_bg_color, 0.8); +} + +.cue-view link:active { + background: shade(@theme_bg_color, 0.6); +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..10c20e6 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,371 @@ +use adw::prelude::*; +use gst::glib::clone; +use gtk::gio::{Menu, MenuItem, SimpleAction, SimpleActionGroup}; +use relm4::{WorkerController, prelude::*}; + +use crate::{ + player::{Player, PlayerMsg, PlayerOutput}, + preferences::{Preferences, PreferencesMsg}, + subtitle_extractor::{ + StreamIndex, SubtitleExtractor, SubtitleExtractorMsg, SubtitleExtractorOutput, TRACKS, + }, + subtitle_view::{SubtitleView, SubtitleViewMsg, SubtitleViewOutput}, + transcript::{Transcript, TranscriptMsg, TranscriptOutput}, + util::OptionTracker, +}; + +const TRACK_SELECTION_ACTION_GROUP_NAME: &str = "subtitle_track_selection"; + +pub struct App { + url: String, + transcript: Controller<Transcript>, + player: Controller<Player>, + subtitle_view: Controller<SubtitleView>, + extractor: WorkerController<SubtitleExtractor>, + preferences: Controller<Preferences>, + + subtitle_selection_menu: Menu, + subtitle_selection_action_group: SimpleActionGroup, + + primary_stream_ix: Option<StreamIndex>, + primary_last_cue_ix: OptionTracker<usize>, + secondary_stream_ix: Option<StreamIndex>, + secondary_last_cue_ix: OptionTracker<usize>, + + // for auto-pausing + autopaused: bool, + primary_cue_active: bool, + hovering_primary_cue: bool, +} + +#[derive(Debug)] +pub enum AppMsg { + NewOrUpdatedTrackMetadata(StreamIndex), + NewCue(StreamIndex, crate::subtitle_extractor::SubtitleCue), + ExtractionComplete, + TrackSelected(StreamIndex), + PositionUpdate(gst::ClockTime), + SetHoveringSubtitleCue(bool), + ShowPreferences, +} + +#[relm4::component(pub)] +impl SimpleComponent for App { + type Init = String; + type Input = AppMsg; + type Output = (); + + view! { + #[root] + adw::ApplicationWindow { + set_title: Some("lleap"), + set_default_width: 800, + set_default_height: 600, + + #[name(toolbar_view)] + adw::ToolbarView { + add_top_bar = &adw::HeaderBar { + pack_start = >k::MenuButton { + set_label: "Select Subtitle Track", + set_popover: Some(>k::PopoverMenu::from_model(Some(&model.subtitle_selection_menu))), + }, + pack_start = >k::Button { + set_label: "Preferences", + connect_clicked => AppMsg::ShowPreferences, + add_css_class: "flat", + } + }, + + #[wrap(Some)] + set_content = >k::Paned { + set_orientation: gtk::Orientation::Vertical, + #[wrap(Some)] + set_start_child = >k::Paned { + set_start_child: Some(model.player.widget()), + set_end_child: Some(model.transcript.widget()), + }, + set_end_child: Some(model.subtitle_view.widget()), + set_shrink_end_child: false, + } + } + } + } + + fn init( + url: Self::Init, + root: Self::Root, + sender: ComponentSender<Self>, + ) -> ComponentParts<Self> { + let subtitle_selection_menu = Menu::new(); + let subtitle_selection_action_group = SimpleActionGroup::new(); + root.insert_action_group( + TRACK_SELECTION_ACTION_GROUP_NAME, + Some(&subtitle_selection_action_group), + ); + Self::add_dummy_menu_item(&subtitle_selection_action_group, &subtitle_selection_menu); + + let subtitle_view = SubtitleView::builder().launch(()).forward( + sender.input_sender(), + |output| match output { + SubtitleViewOutput::SetHoveringCue(val) => AppMsg::SetHoveringSubtitleCue(val), + }, + ); + let player = Player::builder() + .launch(()) + .forward(sender.input_sender(), |output| match output { + PlayerOutput::PositionUpdate(pos) => AppMsg::PositionUpdate(pos), + }); + let transcript = + Transcript::builder() + .launch(()) + .forward(player.sender(), |msg| match msg { + TranscriptOutput::SeekTo(pos) => PlayerMsg::SeekTo(pos), + }); + + let extractor = SubtitleExtractor::builder().detach_worker(()).forward( + sender.input_sender(), + |output| match output { + SubtitleExtractorOutput::NewOrUpdatedTrackMetadata(stream_index) => { + AppMsg::NewOrUpdatedTrackMetadata(stream_index) + } + SubtitleExtractorOutput::NewCue(stream_index, cue) => { + AppMsg::NewCue(stream_index, cue) + } + SubtitleExtractorOutput::ExtractionComplete => AppMsg::ExtractionComplete, + }, + ); + + let preferences = Preferences::builder().launch(root.clone().into()).detach(); + + let model = Self { + url: url.clone(), // TODO remove clone + player, + transcript, + subtitle_view, + extractor, + preferences, + subtitle_selection_menu, + subtitle_selection_action_group, + + primary_stream_ix: None, + primary_last_cue_ix: OptionTracker::new(None), + secondary_stream_ix: None, + secondary_last_cue_ix: OptionTracker::new(None), + + autopaused: false, + primary_cue_active: false, + hovering_primary_cue: false, + }; + + let widgets = view_output!(); + + model + .player + .sender() + .send(PlayerMsg::SetUrl(url.clone())) + .unwrap(); + model + .extractor + .sender() + .send(SubtitleExtractorMsg::ExtractFromUrl(url)) + .unwrap(); + + ComponentParts { model, widgets } + } + + fn update(&mut self, msg: Self::Input, sender: ComponentSender<Self>) { + self.primary_last_cue_ix.reset(); + self.secondary_last_cue_ix.reset(); + + match msg { + AppMsg::NewOrUpdatedTrackMetadata(_stream_index) => { + self.update_subtitle_selection_menu(&sender); + } + AppMsg::NewCue(stream_index, cue) => { + self.transcript + .sender() + .send(TranscriptMsg::NewCue(stream_index, cue)) + .unwrap(); + } + AppMsg::ExtractionComplete => { + println!("Subtitle extraction complete"); + } + AppMsg::TrackSelected(stream_index) => { + self.primary_stream_ix = Some(stream_index); + + self.transcript + .sender() + .send(TranscriptMsg::SelectTrack(stream_index)) + .unwrap(); + } + AppMsg::PositionUpdate(pos) => { + if let Some(stream_ix) = self.primary_stream_ix { + let cue = + Self::get_cue_and_update_ix(stream_ix, pos, &mut self.primary_last_cue_ix); + let cue_is_some = cue.is_some(); + + // beginning of new subtitle + if self.primary_last_cue_ix.is_dirty() + || (!self.primary_cue_active && cue_is_some) + { + self.subtitle_view + .sender() + .send(SubtitleViewMsg::SetPrimaryCue(cue)) + .unwrap(); + self.primary_cue_active = cue_is_some; + + if let Some(ix) = self.primary_last_cue_ix.get() { + self.transcript + .sender() + .send(TranscriptMsg::ScrollToCue(*ix)) + .unwrap(); + } + + self.primary_last_cue_ix.reset(); + } + + // end of current subtitle + if self.primary_cue_active && !cue_is_some && !self.autopaused { + if self.hovering_primary_cue { + self.player.sender().send(PlayerMsg::Pause).unwrap(); + self.autopaused = true; + } else { + self.subtitle_view + .sender() + .send(SubtitleViewMsg::SetPrimaryCue(None)) + .unwrap(); + self.primary_cue_active = false; + } + } + } + if let Some(stream_ix) = self.secondary_stream_ix { + self.subtitle_view + .sender() + .send(SubtitleViewMsg::SetPrimaryCue(Self::get_cue_and_update_ix( + stream_ix, + pos, + &mut self.primary_last_cue_ix, + ))) + .unwrap(); + } + } + AppMsg::SetHoveringSubtitleCue(hovering) => { + self.hovering_primary_cue = hovering; + if !hovering && self.autopaused { + self.player.sender().send(PlayerMsg::Play).unwrap(); + self.autopaused = false; + } + } + AppMsg::ShowPreferences => { + self.preferences + .sender() + .send(PreferencesMsg::Show) + .unwrap(); + } + } + } +} + +impl App { + fn update_subtitle_selection_menu(&mut self, sender: &ComponentSender<Self>) { + self.subtitle_selection_menu.remove_all(); + + for action_name in self.subtitle_selection_action_group.list_actions() { + self.subtitle_selection_action_group + .remove_action(&action_name); + } + + let tracks = TRACKS.read(); + if tracks.is_empty() { + Self::add_dummy_menu_item( + &self.subtitle_selection_action_group, + &self.subtitle_selection_menu, + ); + } else { + for (stream_index, track) in tracks.iter() { + let unknown_string = "<unknown>".to_string(); + let language = track.language_code.as_ref().unwrap_or(&unknown_string); + let label = format!("{} (Stream {})", language, stream_index); + + let action_name = format!("select_{}", stream_index); + let action = SimpleAction::new(&action_name, None); + + action.connect_activate(clone!( + #[strong] + sender, + #[strong] + stream_index, + move |_, _| { + let _ = sender.input(AppMsg::TrackSelected(stream_index)); + } + )); + + self.subtitle_selection_action_group.add_action(&action); + + // Create menu item + let action_target = + format!("{}.{}", TRACK_SELECTION_ACTION_GROUP_NAME, action_name); + let item = MenuItem::new(Some(&label), Some(&action_target)); + self.subtitle_selection_menu.append_item(&item); + } + } + } + + // Add disabled "No tracks available" item + fn add_dummy_menu_item(action_group: &SimpleActionGroup, menu: &Menu) { + let disabled_action = SimpleAction::new("no_tracks", None); + disabled_action.set_enabled(false); + action_group.add_action(&disabled_action); + + let action_target = format!("{}.no_tracks", TRACK_SELECTION_ACTION_GROUP_NAME); + let item = MenuItem::new(Some("No tracks available"), Some(&action_target)); + menu.append_item(&item); + } + + fn get_cue_and_update_ix( + stream_ix: StreamIndex, + position: gst::ClockTime, + last_cue_ix: &mut OptionTracker<usize>, + ) -> Option<String> { + let lock = TRACKS.read(); + let track = lock.get(&stream_ix)?; + + // try to find current cue quickly (should usually succeed during playback) + if let Some(ix) = last_cue_ix.get() { + let last_cue = track.cues.get(*ix)?; + if last_cue.start <= position && position <= last_cue.end { + return Some(last_cue.text.clone()); + } + let next_cue = track.cues.get(ix + 1)?; + if last_cue.end < position && position < next_cue.start { + return None; + } + if next_cue.start <= position && position <= next_cue.end { + last_cue_ix.set(Some(ix + 1)); + return Some(next_cue.text.clone()); + } + } + + // if we are before the first subtitle, no need to look further + if position < track.cues.first()?.start { + last_cue_ix.set(None); + return None; + } + + // otherwise, search the whole track (e.g. after seeking) + let (ix, cue) = track + .cues + .iter() + .enumerate() + .rev() + .find(|(_ix, cue)| cue.start <= position)?; + + last_cue_ix.set(Some(ix)); + + if position <= cue.end { + Some(cue.text.clone()) + } else { + None + } + } +} diff --git a/src/cue_view.rs b/src/cue_view.rs new file mode 100644 index 0000000..c031720 --- /dev/null +++ b/src/cue_view.rs @@ -0,0 +1,179 @@ +use std::ops::Range; +use std::str::FromStr; + +use gtk::gdk; +use gtk::glib; +use gtk::{pango, prelude::*}; +use relm4::prelude::*; +use relm4::{ComponentParts, SimpleComponent}; +use unicode_segmentation::UnicodeSegmentation; + +use crate::util::OptionTracker; + +pub struct CueView { + text: OptionTracker<String>, + // byte ranges for the words in `text` + word_ranges: Vec<Range<usize>>, +} + +#[derive(Debug)] +pub enum CueViewMsg { + // messages from the app + SetText(Option<String>), + // messages from UI + MouseMotion, +} + +#[derive(Debug)] +pub enum CueViewOutput { + MouseEnter, + MouseLeave, +} + +#[relm4::component(pub)] +impl SimpleComponent for CueView { + type Init = (); + type Input = CueViewMsg; + type Output = CueViewOutput; + + view! { + #[root] + #[name(label)] + gtk::Label { + add_controller: event_controller.clone(), + set_use_markup: true, + set_visible: false, + set_justify: gtk::Justification::Center, + add_css_class: "cue-view", + }, + + #[name(event_controller)] + gtk::EventControllerMotion { + connect_enter[sender] => move |_, _, _| { sender.output(CueViewOutput::MouseEnter).unwrap() }, + connect_motion[sender] => move |_, _, _| { sender.input(CueViewMsg::MouseMotion) }, + connect_leave[sender] => move |_| { sender.output(CueViewOutput::MouseLeave).unwrap() }, + }, + + #[name(popover)] + gtk::Popover { + set_parent: &root, + set_position: gtk::PositionType::Top, + set_autohide: false, + + #[name(popover_label)] + gtk::Label { } + } + } + + fn init( + _init: Self::Init, + root: Self::Root, + sender: relm4::ComponentSender<Self>, + ) -> relm4::ComponentParts<Self> { + let model = Self { + text: OptionTracker::new(None), + word_ranges: Vec::new(), + }; + + let widgets = view_output!(); + + ComponentParts { model, widgets } + } + + fn update(&mut self, message: Self::Input, _sender: relm4::ComponentSender<Self>) { + match message { + CueViewMsg::SetText(text) => { + self.text.set(text); + + if let Some(text) = self.text.get() { + self.word_ranges = UnicodeSegmentation::unicode_word_indices(text.as_str()) + .map(|(offset, slice)| Range { + start: offset, + end: offset + slice.len(), + }) + .collect(); + } else { + self.word_ranges = Vec::new(); + } + } + CueViewMsg::MouseMotion => { + // only used to update popover in view + } + } + } + + fn post_view() { + if self.text.is_dirty() { + if let Some(text) = self.text.get() { + let mut markup = String::new(); + + let mut it = self.word_ranges.iter().enumerate().peekable(); + if let Some((_, first_word_range)) = it.peek() { + markup.push_str( + glib::markup_escape_text(&text[..first_word_range.start]).as_str(), + ); + } + while let Some((word_ix, word_range)) = it.next() { + markup.push_str(&format!( + "<a href=\"{}\">{}</a>", + word_ix, + glib::markup_escape_text(&text[word_range.clone()]) + )); + let next_gap_range = if let Some((_, next_word_range)) = it.peek() { + word_range.end..next_word_range.start + } else { + word_range.end..text.len() + }; + markup.push_str(glib::markup_escape_text(&text[next_gap_range]).as_str()); + } + + widgets.label.set_markup(markup.as_str()); + widgets.label.set_visible(true); + } else { + widgets.label.set_visible(false); + } + } + + if let Some(word_ix_str) = widgets.label.current_uri() { + let range = self + .word_ranges + .get(usize::from_str(word_ix_str.as_str()).unwrap()) + .unwrap(); + widgets + .popover_label + .set_text(&self.text.get().as_ref().unwrap()[range.clone()]); + widgets + .popover + .set_pointing_to(Some(&Self::get_rect_of_byte_range(&widgets.label, &range))); + widgets.popover.popup(); + } else { + widgets.popover.popdown(); + } + } + + fn shutdown(&mut self, widgets: &mut Self::Widgets, _output: relm4::Sender<Self::Output>) { + widgets.popover.unparent(); + } +} + +impl CueView { + fn get_rect_of_byte_range(label: >k::Label, range: &Range<usize>) -> gdk::Rectangle { + let layout = label.layout(); + let (offset_x, offset_y) = label.layout_offsets(); + + let start_pos = layout.index_to_pos(range.start as i32); + let end_pos = layout.index_to_pos(range.end as i32); + let (x, width) = if start_pos.x() <= end_pos.x() { + (start_pos.x(), end_pos.x() - start_pos.x()) + } else { + (end_pos.x(), start_pos.x() - end_pos.x()) + }; + + gdk::Rectangle::new( + x / pango::SCALE + offset_x, + start_pos.y() / pango::SCALE + offset_y, + width / pango::SCALE, + start_pos.height() / pango::SCALE, + ) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d902eaa --- /dev/null +++ b/src/main.rs @@ -0,0 +1,45 @@ +mod app; +mod cue_view; +mod player; +mod preferences; +mod subtitle_extractor; +mod subtitle_view; +mod transcript; +mod util; + +use std::env; + +use gtk::{CssProvider, STYLE_PROVIDER_PRIORITY_APPLICATION, gdk, glib}; +use relm4::RelmApp; + +use crate::app::App; + +fn main() { + env_logger::init(); + + let args: Vec<String> = env::args().collect(); + if args.len() != 2 { + eprintln!("Usage: {} <video_url>", args[0]); + std::process::exit(1); + } + let video_url = args[1].clone(); + + gtk::init().expect("Failed to initialize GTK"); + gst::init().expect("Failed to initialize GStreamer"); + ffmpeg::init().expect("Failed to initialize FFmpeg"); + + let css_provider = CssProvider::new(); + css_provider.load_from_bytes(&glib::Bytes::from_static(include_bytes!( + "../resources/style.css" + ))); + gtk::style_context_add_provider_for_display( + &gdk::Display::default().unwrap(), + &css_provider, + STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + relm4::RELM_THREADS.set(4).unwrap(); + + let relm = RelmApp::new("tc.mal.lleap").with_args(vec![]); + relm.run::<App>(video_url); +} diff --git a/src/player.rs b/src/player.rs new file mode 100644 index 0000000..c784a04 --- /dev/null +++ b/src/player.rs @@ -0,0 +1,298 @@ +use gst::bus::BusWatchGuard; +use gst::prelude::*; +use gst_play::{Play, PlayMessage, PlayVideoOverlayVideoRenderer}; +use gtk::gdk; +use gtk::glib::{self, clone}; +use gtk::prelude::*; +use relm4::{ComponentParts, ComponentSender, RelmWidgetExt, SimpleComponent}; + +#[allow(dead_code)] +pub struct Player { + // GStreamer-related + gtksink: gst::Element, + renderer: PlayVideoOverlayVideoRenderer, + player: Play, + bus_watch: BusWatchGuard, + // UI state + is_playing: bool, + duration: gst::ClockTime, + position: gst::ClockTime, + seeking: bool, +} + +#[derive(Debug)] +pub enum PlayerMsg { + SetUrl(String), + PlayPause, + Play, + Pause, + SeekTo(gst::ClockTime), + StartSeeking, + StopSeeking, + // messages from GStreamer + UpdatePosition(gst::ClockTime), + UpdateDuration(gst::ClockTime), +} + +#[derive(Debug)] +pub enum PlayerOutput { + PositionUpdate(gst::ClockTime), +} + +fn format_time(time: gst::ClockTime) -> String { + let seconds = time.seconds(); + let minutes = seconds / 60; + let hours = minutes / 60; + + if hours > 0 { + format!("{}:{:02}:{:02}", hours, minutes % 60, seconds % 60) + } else { + format!("{}:{:02}", minutes, seconds % 60) + } +} + +#[relm4::component(pub)] +impl SimpleComponent for Player { + type Input = PlayerMsg; + type Output = PlayerOutput; + type Init = (); + + view! { + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + + // Video area + gtk::Picture { + set_paintable: Some(&paintable), + set_vexpand: true, + }, + + // Control bar + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_spacing: 10, + set_margin_all: 10, + + // Play/Pause button + #[name = "play_pause_btn"] + gtk::Button { + #[watch] + set_icon_name: if model.is_playing { "media-playback-pause" } else { "media-playback-start" }, + connect_clicked => PlayerMsg::PlayPause, + add_css_class: "circular", + }, + + // Current time label + gtk::Label { + #[watch] + set_text: &format_time(model.position), + set_width_chars: 8, + }, + + // Seek slider + #[name = "seek_scale"] + gtk::Scale { + set_hexpand: true, + set_orientation: gtk::Orientation::Horizontal, + #[watch] + set_range: (0.0, model.duration.mseconds() as f64), + #[watch] + set_value?: if !model.seeking { + Some(model.position.mseconds() as f64) + } else { + None + }, + connect_change_value[sender] => move |_, _, value| { + let position = gst::ClockTime::from_mseconds(value as u64); + sender.input(PlayerMsg::SeekTo(position)); + glib::Propagation::Proceed + }, + }, + + // Duration label + #[name = "duration_label"] + gtk::Label { + #[watch] + set_text: &format_time(model.duration), + set_width_chars: 8, + }, + } + } + } + + fn init( + _init: Self::Init, + _window: Self::Root, + sender: ComponentSender<Self>, + ) -> ComponentParts<Self> { + let gtksink = gst::ElementFactory::make("gtk4paintablesink") + .build() + .expect("Failed to create gtk4paintablesink"); + + // Need to set state to Ready to get a GL context + gtksink + .set_state(gst::State::Ready) + .expect("Failed to set GTK sink state to ready"); + + let paintable = gtksink.property::<gdk::Paintable>("paintable"); + + let sink = if paintable + .property::<Option<gdk::GLContext>>("gl-context") + .is_some() + { + gst::ElementFactory::make("glsinkbin") + .property("sink", >ksink) + .build() + .expect("Failed to build glsinkbin") + } else { + gtksink.clone() + }; + + let renderer = PlayVideoOverlayVideoRenderer::with_sink(&sink); + + let player = Play::new(Some( + renderer.clone().upcast::<gst_play::PlayVideoRenderer>(), + )); + + let mut config = player.config(); + config.set_position_update_interval(5); + player.set_config(config).unwrap(); + + // 100MiB ring buffer to improve seek performance + player + .pipeline() + .set_property("ring-buffer-max-size", 100 * 1024 * 1024 as u64); + + let bus_watch = player + .message_bus() + .add_watch_local(clone!( + #[strong] + sender, + move |_, message| { + let play_message = if let Ok(msg) = PlayMessage::parse(message) { + msg + } else { + return glib::ControlFlow::Continue; + }; + + match play_message { + PlayMessage::Error(error_msg) => { + eprintln!("Playback error: {:?}", error_msg.error()); + if let Some(details) = error_msg.details() { + eprintln!("Error details: {:?}", details); + } + } + PlayMessage::PositionUpdated(pos) => { + if let Some(position) = pos.position() { + sender.input(PlayerMsg::UpdatePosition(position)); + sender + .output(PlayerOutput::PositionUpdate(position)) + .unwrap(); + } + } + PlayMessage::DurationChanged(dur) => { + if let Some(duration) = dur.duration() { + sender.input(PlayerMsg::UpdateDuration(duration)); + } + } + PlayMessage::Buffering(_) => { + // TODO + } + _ => {} + } + + glib::ControlFlow::Continue + } + )) + .expect("Failed to add message bus watch"); + + let model = Player { + gtksink, + renderer, + player, + bus_watch, + is_playing: false, + duration: gst::ClockTime::ZERO, + position: gst::ClockTime::ZERO, + seeking: false, + }; + + let widgets = view_output!(); + + // find the existing GestureClick controller in the Scale widget + // instead of adding a new one to avoid this bug: + // https://gitlab.gnome.org/GNOME/gtk/-/issues/4939 + let gesture = widgets + .seek_scale + .observe_controllers() + .into_iter() + .find_map(|controller| controller.unwrap().downcast::<gtk::GestureClick>().ok()) + .unwrap(); + + gesture.connect_pressed(clone!( + #[strong] + sender, + move |_, _, _, _| { + sender.input(PlayerMsg::StartSeeking); + } + )); + gesture.connect_released(clone!( + #[strong] + sender, + move |_, _, _, _| { + sender.input(PlayerMsg::StopSeeking); + } + )); + + ComponentParts { model, widgets } + } + + fn update(&mut self, msg: Self::Input, _sender: ComponentSender<Self>) { + match msg { + PlayerMsg::SetUrl(url) => { + self.player.set_uri(Some(&url)); + self.play(); + } + PlayerMsg::PlayPause => { + if self.is_playing { + self.pause(); + } else { + self.play(); + } + } + PlayerMsg::Play => self.play(), + PlayerMsg::Pause => self.pause(), + PlayerMsg::SeekTo(position) => { + //self.seek_position = position_ms; + self.player.seek(position); + self.position = position; + } + PlayerMsg::StartSeeking => { + self.seeking = true; + } + PlayerMsg::StopSeeking => { + self.seeking = false; + } + PlayerMsg::UpdatePosition(position) => { + if !self.seeking { + self.position = position; + } + } + PlayerMsg::UpdateDuration(duration) => { + self.duration = duration; + } + } + } +} + +impl Player { + fn play(&mut self) { + self.player.play(); + self.is_playing = true; + } + + fn pause(&mut self) { + self.player.pause(); + self.is_playing = false; + } +} diff --git a/src/preferences.rs b/src/preferences.rs new file mode 100644 index 0000000..c5f9bb1 --- /dev/null +++ b/src/preferences.rs @@ -0,0 +1,71 @@ +use adw::prelude::*; +use gtk::gio; +use relm4::prelude::*; + +pub struct Preferences { + parent_window: adw::ApplicationWindow, + dialog: adw::PreferencesDialog, +} + +#[derive(Debug)] +pub enum PreferencesMsg { + Show, +} + +#[derive(Debug)] +pub enum PreferencesOutput {} + +#[relm4::component(pub)] +impl SimpleComponent for Preferences { + type Init = adw::ApplicationWindow; + type Input = PreferencesMsg; + type Output = PreferencesOutput; + + view! { + #[root] + adw::PreferencesDialog { + set_title: "Preferences", + add: &page, + }, + + #[name(page)] + adw::PreferencesPage { + adw::PreferencesGroup { + set_title: "Machine Translation", + + adw::EntryRow { + set_title: "DeepL API key", + set_text: settings.string("deepl-api-key").as_str(), + connect_changed[settings] => move |entry| { + settings.set_string("deepl-api-key", entry.text().as_str()).unwrap() + } + }, + } + } + } + + fn init( + parent_window: Self::Init, + root: Self::Root, + _sender: ComponentSender<Self>, + ) -> ComponentParts<Self> { + let settings = gio::Settings::new("tc.mal.lleap"); + + let model = Self { + parent_window, + dialog: root.clone(), + }; + + let widgets = view_output!(); + + ComponentParts { model, widgets } + } + + fn update(&mut self, msg: Self::Input, _sender: ComponentSender<Self>) { + match msg { + PreferencesMsg::Show => { + self.dialog.present(Some(&self.parent_window)); + } + } + } +} diff --git a/src/subtitle_extractor.rs b/src/subtitle_extractor.rs new file mode 100644 index 0000000..53655a0 --- /dev/null +++ b/src/subtitle_extractor.rs @@ -0,0 +1,209 @@ +use std::collections::BTreeMap; + +use anyhow::Result; + +use ffmpeg::Rational; +use log::{debug, error, info}; +use relm4::{ComponentSender, SharedState, Worker}; + +pub type StreamIndex = usize; + +#[derive(Debug, Clone)] +pub struct SubtitleCue { + pub start: gst::ClockTime, + pub end: gst::ClockTime, + pub text: String, +} + +#[derive(Debug, Clone)] +pub struct SubtitleTrack { + pub language_code: Option<String>, + pub title: Option<String>, + pub cues: Vec<SubtitleCue>, +} + +pub static TRACKS: SharedState<BTreeMap<StreamIndex, SubtitleTrack>> = SharedState::new(); + +pub struct SubtitleExtractor {} + +#[derive(Debug)] +pub enum SubtitleExtractorMsg { + ExtractFromUrl(String), +} + +#[derive(Debug)] +pub enum SubtitleExtractorOutput { + NewOrUpdatedTrackMetadata(StreamIndex), + NewCue(StreamIndex, SubtitleCue), + ExtractionComplete, +} + +impl Worker for SubtitleExtractor { + type Init = (); + type Input = SubtitleExtractorMsg; + type Output = SubtitleExtractorOutput; + + fn init(_init: Self::Init, _sender: ComponentSender<Self>) -> Self { + Self {} + } + + fn update(&mut self, msg: SubtitleExtractorMsg, sender: ComponentSender<Self>) { + match msg { + SubtitleExtractorMsg::ExtractFromUrl(url) => { + self.handle_extract_from_url(url, sender); + } + } + } +} + +impl SubtitleExtractor { + fn handle_extract_from_url(&mut self, url: String, sender: ComponentSender<Self>) { + // Clear existing tracks + TRACKS.write().clear(); + + // Try to extract subtitles using ffmpeg + match self.extract_subtitles_ffmpeg(&url, &sender) { + Ok(_) => { + info!("Subtitle extraction completed successfully"); + sender + .output(SubtitleExtractorOutput::ExtractionComplete) + .unwrap(); + } + Err(e) => { + error!("FFmpeg extraction failed: {}", e); + } + } + } + + fn extract_subtitles_ffmpeg(&self, url: &str, sender: &ComponentSender<Self>) -> Result<()> { + let mut input = ffmpeg::format::input(&url)?; + + let mut subtitle_decoders = BTreeMap::new(); + + // create decoder for each subtitle stream + for (stream_index, stream) in input.streams().enumerate() { + if stream.parameters().medium() == ffmpeg::media::Type::Subtitle { + let language_code = stream.metadata().get("language").map(|s| s.to_string()); + let title = stream.metadata().get("title").map(|s| s.to_string()); + + let track = SubtitleTrack { + language_code, + title, + cues: Vec::new(), + }; + + TRACKS.write().insert(stream_index, track); + + sender + .output(SubtitleExtractorOutput::NewOrUpdatedTrackMetadata( + stream_index, + )) + .unwrap(); + + let context = + ffmpeg::codec::context::Context::from_parameters(stream.parameters())?; + if let Ok(decoder) = context.decoder().subtitle() { + subtitle_decoders.insert(stream_index, decoder); + debug!("Created decoder for subtitle stream {}", stream_index); + } else { + error!( + "Failed to create decoder for subtitle stream {}", + stream_index + ); + } + } + } + + // process packets + for (stream, packet) in input.packets() { + let stream_index = stream.index(); + + if let Some(decoder) = subtitle_decoders.get_mut(&stream_index) { + let mut subtitle = ffmpeg::Subtitle::new(); + if decoder.decode(&packet, &mut subtitle).is_ok() { + if let Some(cue) = Self::subtitle_to_cue(&subtitle, &packet, stream.time_base()) + { + if let Some(track) = TRACKS.write().get_mut(&stream_index) { + track.cues.push(cue.clone()); + } + + sender + .output(SubtitleExtractorOutput::NewCue(stream_index, cue)) + .unwrap(); + } + } + } + } + + Ok(()) + } + + fn subtitle_to_cue( + subtitle: &ffmpeg::Subtitle, + packet: &ffmpeg::Packet, + time_base: Rational, + ) -> Option<SubtitleCue> { + let time_to_clock_time = |time: i64| { + let nseconds: i64 = (time * time_base.numerator() as i64 * 1_000_000_000) + / time_base.denominator() as i64; + gst::ClockTime::from_nseconds(nseconds as u64) + }; + + let text = subtitle + .rects() + .into_iter() + .map(|rect| match rect { + ffmpeg::subtitle::Rect::Text(text) => text.get().to_string(), + ffmpeg::subtitle::Rect::Ass(ass) => { + Self::extract_dialogue_text(ass.get()).unwrap_or(String::new()) + } + _ => String::new(), + }) + .collect::<Vec<String>>() + .join("\n— "); + + let start = time_to_clock_time(packet.pts()?); + let end = time_to_clock_time(packet.pts()? + packet.duration()); + + Some(SubtitleCue { start, end, text }) + } + + fn extract_dialogue_text(dialogue_line: &str) -> Option<String> { + // ASS dialogue format: ReadOrder,Layer,Style,Name,MarginL,MarginR,MarginV,Effect,Text + // we need the 9th field (Text), so split on comma but only take first 9 splits + // see also https://github.com/FFmpeg/FFmpeg/blob/a700f0f72d1f073e5adcfbb16f4633850b0ef51c/libavcodec/ass_split.c#L433 + let text = dialogue_line.splitn(9, ',').last()?; + + // remove ASS override codes (formatting tags) like {\b1}, {\i1}, {\c&Hffffff&}, etc. + let mut result = String::new(); + let mut in_tag = false; + let mut char_iter = text.chars().peekable(); + + while let Some(c) = char_iter.next() { + if c == '{' && char_iter.peek() == Some(&'\\') { + in_tag = true; + } else if c == '}' { + in_tag = false; + } else if !in_tag { + // process line breaks and hard spaces + if c == '\\' { + match char_iter.peek() { + Some(&'N') => { + char_iter.next(); + result.push('\n'); + } + Some(&'n') | Some(&'h') => { + char_iter.next(); + result.push(' '); + } + _ => result.push(c), + } + } else { + result.push(c); + } + } + } + + Some(result) + } +} diff --git a/src/subtitle_view.rs b/src/subtitle_view.rs new file mode 100644 index 0000000..30c089c --- /dev/null +++ b/src/subtitle_view.rs @@ -0,0 +1,94 @@ +use crate::cue_view::{CueView, CueViewMsg, CueViewOutput}; +use crate::util::OptionTracker; +use gtk::prelude::*; +use relm4::prelude::*; + +pub struct SubtitleView { + primary_cue: Controller<CueView>, + secondary_cue: OptionTracker<String>, +} + +#[derive(Debug)] +pub enum SubtitleViewMsg { + SetPrimaryCue(Option<String>), + SetSecondaryCue(Option<String>), +} + +#[derive(Debug)] +pub enum SubtitleViewOutput { + SetHoveringCue(bool), +} + +#[relm4::component(pub)] +impl SimpleComponent for SubtitleView { + type Init = (); + type Input = SubtitleViewMsg; + type Output = SubtitleViewOutput; + + view! { + gtk::ScrolledWindow { + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_vexpand: true, + set_halign: gtk::Align::Center, + + gtk::Box { + set_vexpand: true, + }, + + model.primary_cue.widget(), + + gtk::Box { + set_vexpand: true, + }, + + gtk::Label { + #[track = "model.secondary_cue.is_dirty()"] + set_text: model.secondary_cue.get().as_ref().map(|val| val.as_str()).unwrap_or("TODO placeholder"), + set_justify: gtk::Justification::Center, + }, + + gtk::Box { + set_vexpand: true, + }, + } + }, + } + + fn init( + _init: Self::Init, + root: Self::Root, + sender: ComponentSender<Self>, + ) -> ComponentParts<Self> { + let model = Self { + primary_cue: CueView::builder() + .launch(()) + .forward(sender.output_sender(), |output| match output { + CueViewOutput::MouseEnter => SubtitleViewOutput::SetHoveringCue(true), + CueViewOutput::MouseLeave => SubtitleViewOutput::SetHoveringCue(false), + }), + secondary_cue: OptionTracker::default(), + }; + + let widgets = view_output!(); + + ComponentParts { model, widgets } + } + + fn update(&mut self, msg: Self::Input, _sender: ComponentSender<Self>) { + // Reset trackers + self.secondary_cue.reset(); + + match msg { + SubtitleViewMsg::SetPrimaryCue(value) => { + self.primary_cue + .sender() + .send(CueViewMsg::SetText(value)) + .unwrap(); + } + SubtitleViewMsg::SetSecondaryCue(value) => { + self.secondary_cue.set(value); + } + } + } +} diff --git a/src/transcript.rs b/src/transcript.rs new file mode 100644 index 0000000..2bddb72 --- /dev/null +++ b/src/transcript.rs @@ -0,0 +1,143 @@ +use gtk::{ListBox, pango::WrapMode, prelude::*}; +use relm4::prelude::*; + +use crate::subtitle_extractor::{StreamIndex, SubtitleCue, TRACKS}; + +#[derive(Debug)] +pub enum SubtitleCueOutput { + SeekTo(gst::ClockTime), +} + +#[relm4::factory(pub)] +impl FactoryComponent for SubtitleCue { + type Init = Self; + type Input = (); + type Output = SubtitleCueOutput; + type CommandOutput = (); + type ParentWidget = gtk::ListBox; + + view! { + gtk::Button { + inline_css: "padding: 5px; border-radius: 0;", + connect_clicked: { + let start = self.start; + move |_| { + sender.output(SubtitleCueOutput::SeekTo(start)).unwrap() + } + }, + + gtk::Label { + set_label: &self.text, + set_wrap: true, + set_wrap_mode: WrapMode::Word, + set_xalign: 0.0, + add_css_class: "body", + } + } + } + + fn init_model(init: Self::Init, _index: &Self::Index, _sender: FactorySender<Self>) -> Self { + init + } +} + +pub struct Transcript { + active_stream_index: Option<StreamIndex>, + active_cues: FactoryVecDeque<SubtitleCue>, + pending_scroll: Option<usize>, +} + +#[derive(Debug)] +pub enum TranscriptMsg { + NewCue(StreamIndex, SubtitleCue), + SelectTrack(StreamIndex), + ScrollToCue(usize), +} + +#[derive(Debug)] +pub enum TranscriptOutput { + SeekTo(gst::ClockTime), +} + +pub struct TranscriptWidgets { + viewport: gtk::Viewport, +} + +impl SimpleComponent for Transcript { + type Init = (); + type Input = TranscriptMsg; + type Output = TranscriptOutput; + type Widgets = TranscriptWidgets; + type Root = gtk::ScrolledWindow; + + fn init( + _init: Self::Init, + root: Self::Root, + sender: ComponentSender<Self>, + ) -> ComponentParts<Self> { + let listbox = ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .build(); + + let active_cues = + FactoryVecDeque::builder() + .launch(listbox) + .forward(sender.output_sender(), |output| match output { + SubtitleCueOutput::SeekTo(pos) => TranscriptOutput::SeekTo(pos), + }); + + let model = Self { + active_stream_index: None, + active_cues, + pending_scroll: None, + }; + + let widgets = TranscriptWidgets { + viewport: gtk::Viewport::builder().build(), + }; + + widgets.viewport.set_child(Some(model.active_cues.widget())); + root.set_child(Some(&widgets.viewport)); + + ComponentParts { model, widgets } + } + + fn init_root() -> Self::Root { + gtk::ScrolledWindow::new() + } + + fn update(&mut self, msg: Self::Input, _sender: ComponentSender<Self>) { + self.pending_scroll = None; + + match msg { + TranscriptMsg::NewCue(stream_index, cue) => { + if self.active_stream_index == Some(stream_index) { + self.active_cues.guard().push_back(cue); + } + } + TranscriptMsg::SelectTrack(stream_index) => { + self.active_stream_index = Some(stream_index); + + // Clear current widgets and populate with selected track's cues + self.active_cues.guard().clear(); + let tracks = TRACKS.read(); + if let Some(track) = tracks.get(&stream_index) { + for cue in &track.cues { + self.active_cues.guard().push_back(cue.clone()); + } + } + } + TranscriptMsg::ScrollToCue(ix) => { + self.pending_scroll = Some(ix); + } + } + } + + fn update_view(&self, widgets: &mut Self::Widgets, _sender: ComponentSender<Self>) { + if let Some(ix) = self.pending_scroll { + if let Some(row) = self.active_cues.widget().row_at_index(ix as i32) { + widgets.viewport.scroll_to(&row, None); + } + } + } +} diff --git a/src/util/mod.rs b/src/util/mod.rs new file mode 100644 index 0000000..5b0c6ac --- /dev/null +++ b/src/util/mod.rs @@ -0,0 +1,3 @@ +mod option_tracker; + +pub use option_tracker::OptionTracker; diff --git a/src/util/option_tracker.rs b/src/util/option_tracker.rs new file mode 100644 index 0000000..3c19ee5 --- /dev/null +++ b/src/util/option_tracker.rs @@ -0,0 +1,43 @@ +pub struct OptionTracker<T> { + inner: Option<T>, + dirty: bool, +} + +/// Tracks changes to an inner Option<T>. Any change using `set` will cause the +/// tracker to be marked as dirty, unless both the current and new value are +/// `None`. This should be used when changes from `Some(something)` to +/// `Some(something_different)` are rare, or when comparing inner values is more +/// expensive than performing an update which will mark the tracker as clean. +impl<T> OptionTracker<T> { + pub fn new(inner: Option<T>) -> Self { + Self { inner, dirty: true } + } + + pub fn get(&self) -> &Option<T> { + &self.inner + } + + pub fn set(&mut self, value: Option<T>) { + match (&self.inner, &value) { + (None, None) => {} + _ => self.dirty = true, + } + + self.inner = value; + } + + pub fn is_dirty(&self) -> bool { + self.dirty + } + + /// Marks the tracker as clean. + pub fn reset(&mut self) { + self.dirty = false; + } +} + +impl<T> Default for OptionTracker<T> { + fn default() -> Self { + Self::new(Option::default()) + } +} |