summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.envrc1
-rw-r--r--.gitignore3
-rw-r--r--Cargo.lock1868
-rw-r--r--Cargo.toml26
-rw-r--r--build.rs33
-rw-r--r--data/tc.mal.lleap.gschema.xml10
-rw-r--r--flake.lock161
-rw-r--r--flake.nix76
-rw-r--r--resources/style.css19
-rw-r--r--src/app.rs371
-rw-r--r--src/cue_view.rs179
-rw-r--r--src/main.rs45
-rw-r--r--src/player.rs298
-rw-r--r--src/preferences.rs71
-rw-r--r--src/subtitle_extractor.rs209
-rw-r--r--src/subtitle_view.rs94
-rw-r--r--src/transcript.rs143
-rw-r--r--src/util/mod.rs3
-rw-r--r--src/util/option_tracker.rs43
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 = &gtk::MenuButton {
+                        set_label: "Select Subtitle Track",
+                        set_popover: Some(&gtk::PopoverMenu::from_model(Some(&model.subtitle_selection_menu))),
+                    },
+                    pack_start = &gtk::Button {
+                        set_label: "Preferences",
+                        connect_clicked => AppMsg::ShowPreferences,
+                        add_css_class: "flat",
+                    }
+                },
+
+                #[wrap(Some)]
+                set_content = &gtk::Paned {
+                    set_orientation: gtk::Orientation::Vertical,
+                    #[wrap(Some)]
+                    set_start_child = &gtk::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: &gtk::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", &gtksink)
+                .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())
+    }
+}