basic working history (#104)

This commit is contained in:
CJ Pais 2025-09-05 16:25:38 -07:00 committed by GitHub
parent d6684ccb21
commit 7638f6b827
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1427 additions and 15 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -19,6 +19,7 @@
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "~2",
"@tauri-apps/plugin-process": "~2",
"@tauri-apps/plugin-sql": "~2",
"@tauri-apps/plugin-store": "~2",
"@tauri-apps/plugin-stronghold": "~2",
"@tauri-apps/plugin-updater": "~2",

654
src-tauri/Cargo.lock generated
View file

@ -17,6 +17,18 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy 0.8.26",
]
[[package]]
name = "aho-corasick"
version = "1.1.3"
@ -41,6 +53,12 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "alsa"
version = "0.9.1"
@ -322,6 +340,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "atoi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
dependencies = [
"num-traits",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@ -372,6 +399,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
[[package]]
name = "bindgen"
version = "0.69.5"
@ -642,8 +675,10 @@ checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-targets 0.52.6",
]
@ -731,6 +766,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "convert_case"
version = "0.4.0"
@ -896,6 +937,21 @@ dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.4.2"
@ -914,6 +970,15 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@ -1023,6 +1088,17 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"pem-rfc7468",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.3.11"
@ -1064,7 +1140,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"subtle",
]
[[package]]
@ -1158,6 +1236,12 @@ dependencies = [
"syn 2.0.96",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "dpi"
version = "0.1.1"
@ -1211,6 +1295,9 @@ name = "either"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
dependencies = [
"serde",
]
[[package]]
name = "embed-resource"
@ -1374,6 +1461,17 @@ version = "3.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d9305ccc6942a704f4335694ecd3de2ea531b114ac2d51f5f843750787a92f"
[[package]]
name = "etcetera"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
dependencies = [
"cfg-if",
"home",
"windows-sys 0.48.0",
]
[[package]]
name = "event-listener"
version = "5.4.0"
@ -1411,6 +1509,18 @@ dependencies = [
"once_cell",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.3.0"
@ -1458,12 +1568,29 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "flume"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
dependencies = [
"futures-core",
"futures-sink",
"spin",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
@ -1538,6 +1665,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@ -1557,6 +1685,17 @@ dependencies = [
"futures-util",
]
[[package]]
name = "futures-intrusive"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
dependencies = [
"futures-core",
"lock_api",
"parking_lot",
]
[[package]]
name = "futures-io"
version = "0.3.31"
@ -1995,6 +2134,7 @@ name = "handy"
version = "0.3.9"
dependencies = [
"anyhow",
"chrono",
"cpal",
"enigo",
"env_logger",
@ -2008,6 +2148,7 @@ dependencies = [
"reqwest 0.11.27",
"rodio",
"rubato",
"rusqlite",
"rustfft",
"serde",
"serde_json",
@ -2023,6 +2164,7 @@ dependencies = [
"tauri-plugin-os",
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-sql",
"tauri-plugin-store",
"tauri-plugin-updater",
"tokio",
@ -2036,11 +2178,43 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.2",
]
[[package]]
name = "heck"
@ -2066,6 +2240,24 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "home"
version = "0.5.11"
@ -2149,6 +2341,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "httparse"
version = "1.10.0"
@ -2665,6 +2863,9 @@ name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
"spin",
]
[[package]]
name = "lazycell"
@ -2722,6 +2923,12 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "libm"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
[[package]]
name = "libredox"
version = "0.1.3"
@ -2742,6 +2949,17 @@ dependencies = [
"cmake",
]
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@ -2851,6 +3069,16 @@ dependencies = [
"rawpointer",
]
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "memchr"
version = "2.7.4"
@ -3072,6 +3300,23 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151"
dependencies = [
"byteorder",
"lazy_static",
"libm",
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.5",
"smallvec",
"zeroize",
]
[[package]]
name = "num-complex"
version = "0.4.6"
@ -3107,6 +3352,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
@ -3125,6 +3381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
@ -3657,6 +3914,15 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]]
name = "percent-encoding"
version = "2.3.1"
@ -3820,6 +4086,27 @@ dependencies = [
"futures-io",
]
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der",
"pkcs8",
"spki",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.31"
@ -3894,7 +4181,7 @@ version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
dependencies = [
"zerocopy",
"zerocopy 0.7.35",
]
[[package]]
@ -4364,6 +4651,26 @@ dependencies = [
"symphonia",
]
[[package]]
name = "rsa"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b"
dependencies = [
"const-oid",
"digest",
"num-bigint-dig",
"num-integer",
"num-traits",
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
"zeroize",
]
[[package]]
name = "rubato"
version = "0.16.2"
@ -4376,6 +4683,20 @@ dependencies = [
"realfft",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags 2.8.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink 0.9.1",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rust-stemmers"
version = "1.2.0"
@ -4776,6 +5097,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.8"
@ -4802,6 +5134,16 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.7"
@ -4834,6 +5176,9 @@ name = "smallvec"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
dependencies = [
"serde",
]
[[package]]
name = "socket2"
@ -4909,6 +5254,211 @@ name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "sqlx"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
dependencies = [
"sqlx-core",
"sqlx-macros",
"sqlx-mysql",
"sqlx-postgres",
"sqlx-sqlite",
]
[[package]]
name = "sqlx-core"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64 0.22.1",
"bytes",
"crc",
"crossbeam-queue",
"either",
"event-listener",
"futures-core",
"futures-intrusive",
"futures-io",
"futures-util",
"hashbrown 0.15.2",
"hashlink 0.10.0",
"indexmap 2.7.1",
"log",
"memchr",
"once_cell",
"percent-encoding",
"serde",
"serde_json",
"sha2",
"smallvec",
"thiserror 2.0.11",
"time",
"tokio",
"tokio-stream",
"tracing",
"url",
]
[[package]]
name = "sqlx-macros"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
dependencies = [
"proc-macro2",
"quote",
"sqlx-core",
"sqlx-macros-core",
"syn 2.0.96",
]
[[package]]
name = "sqlx-macros-core"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
dependencies = [
"dotenvy",
"either",
"heck 0.5.0",
"hex",
"once_cell",
"proc-macro2",
"quote",
"serde",
"serde_json",
"sha2",
"sqlx-core",
"sqlx-mysql",
"sqlx-postgres",
"sqlx-sqlite",
"syn 2.0.96",
"tokio",
"url",
]
[[package]]
name = "sqlx-mysql"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64 0.22.1",
"bitflags 2.8.0",
"byteorder",
"bytes",
"crc",
"digest",
"dotenvy",
"either",
"futures-channel",
"futures-core",
"futures-io",
"futures-util",
"generic-array",
"hex",
"hkdf",
"hmac",
"itoa",
"log",
"md-5",
"memchr",
"once_cell",
"percent-encoding",
"rand 0.8.5",
"rsa",
"serde",
"sha1",
"sha2",
"smallvec",
"sqlx-core",
"stringprep",
"thiserror 2.0.11",
"time",
"tracing",
"whoami",
]
[[package]]
name = "sqlx-postgres"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64 0.22.1",
"bitflags 2.8.0",
"byteorder",
"crc",
"dotenvy",
"etcetera",
"futures-channel",
"futures-core",
"futures-util",
"hex",
"hkdf",
"hmac",
"home",
"itoa",
"log",
"md-5",
"memchr",
"once_cell",
"rand 0.8.5",
"serde",
"serde_json",
"sha2",
"smallvec",
"sqlx-core",
"stringprep",
"thiserror 2.0.11",
"time",
"tracing",
"whoami",
]
[[package]]
name = "sqlx-sqlite"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
dependencies = [
"atoi",
"flume",
"futures-channel",
"futures-core",
"futures-executor",
"futures-intrusive",
"futures-util",
"libsqlite3-sys",
"log",
"percent-encoding",
"serde",
"serde_urlencoded",
"sqlx-core",
"thiserror 2.0.11",
"time",
"tracing",
"url",
]
[[package]]
name = "stable_deref_trait"
@ -4954,6 +5504,17 @@ dependencies = [
"quote",
]
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.11.1"
@ -5317,6 +5878,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http 1.2.0",
"http-range",
"image",
"jni",
"libc",
@ -5576,6 +6138,25 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-sql"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df059378695202fef1e274b8e7916fc3dffc44716ae4baf8c0226089b2f390ae"
dependencies = [
"futures-core",
"indexmap 2.7.1",
"log",
"serde",
"serde_json",
"sqlx",
"tauri",
"tauri-plugin",
"thiserror 2.0.11",
"time",
"tokio",
]
[[package]]
name = "tauri-plugin-store"
version = "2.2.0"
@ -5901,6 +6482,17 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.13"
@ -6003,6 +6595,7 @@ version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@ -6130,12 +6723,33 @@ dependencies = [
"unic-common",
]
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034"
[[package]]
name = "unicode-normalization"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
@ -6314,6 +6928,12 @@ dependencies = [
"wit-bindgen-rt",
]
[[package]]
name = "wasite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.100"
@ -6546,6 +7166,16 @@ dependencies = [
"fs_extra",
]
[[package]]
name = "whoami"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
dependencies = [
"libredox",
"wasite",
]
[[package]]
name = "widestring"
version = "1.1.0"
@ -7388,7 +8018,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
dependencies = [
"byteorder",
"zerocopy-derive",
"zerocopy-derive 0.7.35",
]
[[package]]
name = "zerocopy"
version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f"
dependencies = [
"zerocopy-derive 0.8.26",
]
[[package]]
@ -7402,6 +8041,17 @@ dependencies = [
"syn 2.0.96",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.96",
]
[[package]]
name = "zerofrom"
version = "0.1.5"

View file

@ -28,7 +28,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
once_cell = "1"
tauri = { version = "2", features = [
tauri = { version = "2", features = [ "protocol-asset",
"macos-private-api",
"tray-icon",
'image-png',
@ -58,6 +58,9 @@ tauri-plugin-fs = "2"
rustfft = "6.4.0"
strsim = "0.11.0"
natural = "0.5.0"
chrono = "0.4"
rusqlite = { version = "0.32", features = ["bundled"] }
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
[dependencies.ort-sys]
version = "=2.0.0-rc.9"

View file

@ -1,11 +1,12 @@
use crate::audio_feedback::{play_recording_start_sound, play_recording_stop_sound};
use crate::managers::audio::AudioRecordingManager;
use crate::managers::history::HistoryManager;
use crate::managers::transcription::TranscriptionManager;
use crate::overlay::{show_recording_overlay, show_transcribing_overlay};
use crate::settings::get_settings;
use crate::tray::{change_tray_icon, TrayIconState};
use crate::utils;
use log::debug;
use log::{debug, error};
use once_cell::sync::Lazy;
use std::collections::HashMap;
use std::sync::Arc;
@ -76,6 +77,7 @@ impl ShortcutAction for TranscribeAction {
let ah = app.clone();
let rm = Arc::clone(&app.state::<Arc<AudioRecordingManager>>());
let tm = Arc::clone(&app.state::<Arc<TranscriptionManager>>());
let hm = Arc::clone(&app.state::<Arc<HistoryManager>>());
change_tray_icon(app, TrayIconState::Transcribing);
show_transcribing_overlay(app);
@ -101,6 +103,7 @@ impl ShortcutAction for TranscribeAction {
);
let transcription_time = Instant::now();
let samples_clone = samples.clone(); // Clone for history saving
match tm.transcribe(samples) {
Ok(transcription) => {
debug!(
@ -109,6 +112,17 @@ impl ShortcutAction for TranscribeAction {
transcription
);
if !transcription.is_empty() {
// Save to history
let hm_clone = Arc::clone(&hm);
let transcription_for_history = transcription.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = hm_clone
.save_transcription(samples_clone, transcription_for_history)
.await
{
error!("Failed to save transcription to history: {}", e);
}
});
let transcription_clone = transcription.clone();
let ah_clone = ah.clone();
let paste_time = Instant::now();

View file

@ -2,9 +2,11 @@
mod device;
mod recorder;
mod resampler;
mod utils;
mod visualizer;
pub use device::{list_input_devices, list_output_devices, CpalDeviceInfo};
pub use recorder::AudioRecorder;
pub use resampler::FrameResampler;
pub use utils::save_wav_file;
pub use visualizer::AudioVisualiser;

View file

@ -0,0 +1,26 @@
use anyhow::Result;
use hound::{WavSpec, WavWriter};
use log::debug;
use std::path::Path;
/// Save audio samples as a WAV file
pub async fn save_wav_file<P: AsRef<Path>>(file_path: P, samples: &[f32]) -> Result<()> {
let spec = WavSpec {
channels: 1,
sample_rate: 16000,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut writer = WavWriter::create(file_path.as_ref(), spec)?;
// Convert f32 samples to i16 for WAV
for sample in samples {
let sample_i16 = (sample * i16::MAX as f32) as i16;
writer.write_sample(sample_i16)?;
}
writer.finalize()?;
debug!("Saved WAV file: {:?}", file_path.as_ref());
Ok(())
}

View file

@ -2,5 +2,7 @@ pub mod audio;
pub mod constants;
pub mod vad;
pub use audio::{list_input_devices, list_output_devices, AudioRecorder, CpalDeviceInfo};
pub use audio::{
list_input_devices, list_output_devices, save_wav_file, AudioRecorder, CpalDeviceInfo,
};
pub use vad::{SileroVad, VoiceActivityDetector};

View file

@ -0,0 +1,38 @@
use crate::managers::history::{HistoryEntry, HistoryManager};
use std::sync::Arc;
use tauri::{AppHandle, State};
#[tauri::command]
pub async fn get_history_entries(
_app: AppHandle,
history_manager: State<'_, Arc<HistoryManager>>,
) -> Result<Vec<HistoryEntry>, String> {
history_manager
.get_history_entries()
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn toggle_history_entry_saved(
_app: AppHandle,
history_manager: State<'_, Arc<HistoryManager>>,
id: i64,
) -> Result<(), String> {
history_manager
.toggle_saved_status(id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_audio_file_path(
_app: AppHandle,
history_manager: State<'_, Arc<HistoryManager>>,
file_name: String,
) -> Result<String, String> {
let path = history_manager.get_audio_file_path(&file_name);
path.to_str()
.ok_or_else(|| "Invalid file path".to_string())
.map(|s| s.to_string())
}

View file

@ -1,4 +1,5 @@
pub mod audio;
pub mod history;
pub mod models;
pub mod transcription;

View file

@ -11,6 +11,7 @@ mod tray;
mod utils;
use managers::audio::AudioRecordingManager;
use managers::history::HistoryManager;
use managers::model::ModelManager;
use managers::transcription::TranscriptionManager;
use std::collections::HashMap;
@ -75,6 +76,14 @@ pub fn run() {
.plugin(tauri_plugin_macos_permissions::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations(
"sqlite:history.db",
managers::history::HistoryManager::get_migrations(),
)
.build(),
)
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
@ -134,11 +143,14 @@ pub fn run() {
TranscriptionManager::new(&app, model_manager.clone())
.expect("Failed to initialize transcription manager"),
);
let history_manager =
Arc::new(HistoryManager::new(&app).expect("Failed to initialize history manager"));
// Add managers to Tauri's managed state
app.manage(recording_manager.clone());
app.manage(model_manager.clone());
app.manage(transcription_manager.clone());
app.manage(history_manager.clone());
// Create the recording overlay window (hidden by default)
utils::create_recording_overlay(&app.handle());
@ -206,7 +218,10 @@ pub fn run() {
commands::audio::get_selected_output_device,
commands::transcription::set_model_unload_timeout,
commands::transcription::get_model_load_status,
commands::transcription::unload_model_manually
commands::transcription::unload_model_manually,
commands::history::get_history_entries,
commands::history::toggle_history_entry_saved,
commands::history::get_audio_file_path
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View file

@ -0,0 +1,250 @@
use anyhow::Result;
use chrono::{DateTime, Local, Utc};
use log::{debug, error};
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::{App, AppHandle, Emitter, Manager};
use tauri_plugin_sql::{Migration, MigrationKind};
use crate::audio_toolkit::save_wav_file;
const HISTORY_LIMIT: usize = 5;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HistoryEntry {
pub id: i64,
pub file_name: String,
pub timestamp: i64,
pub saved: bool,
pub title: String,
pub transcription_text: String,
}
#[derive(Clone)]
pub struct HistoryManager {
app_handle: AppHandle,
recordings_dir: PathBuf,
db_path: PathBuf,
}
impl HistoryManager {
pub fn new(app: &App) -> Result<Self> {
let app_handle = app.app_handle().clone();
// Create recordings directory in app data dir
let app_data_dir = app.path().app_data_dir()?;
let recordings_dir = app_data_dir.join("recordings");
let db_path = app_data_dir.join("history.db");
// Ensure recordings directory exists
if !recordings_dir.exists() {
fs::create_dir_all(&recordings_dir)?;
debug!("Created recordings directory: {:?}", recordings_dir);
}
let manager = Self {
app_handle,
recordings_dir,
db_path,
};
// Initialize database
manager.init_database()?;
Ok(manager)
}
pub fn get_migrations() -> Vec<Migration> {
vec![Migration {
version: 1,
description: "create_transcription_history_table",
sql: "CREATE TABLE IF NOT EXISTS transcription_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_name TEXT NOT NULL,
timestamp INTEGER NOT NULL,
saved BOOLEAN NOT NULL DEFAULT 0,
title TEXT NOT NULL,
transcription_text TEXT NOT NULL
);",
kind: MigrationKind::Up,
}]
}
fn init_database(&self) -> Result<()> {
let conn = Connection::open(&self.db_path)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS transcription_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_name TEXT NOT NULL,
timestamp INTEGER NOT NULL,
saved BOOLEAN NOT NULL DEFAULT 0,
title TEXT NOT NULL,
transcription_text TEXT NOT NULL
)",
[],
)?;
debug!("Database initialized at: {:?}", self.db_path);
Ok(())
}
fn get_connection(&self) -> Result<Connection> {
Ok(Connection::open(&self.db_path)?)
}
/// Save a transcription to history (both database and WAV file)
pub async fn save_transcription(
&self,
audio_samples: Vec<f32>,
transcription_text: String,
) -> Result<()> {
let timestamp = Utc::now().timestamp();
let file_name = format!("handy-{}.wav", timestamp);
let title = self.format_timestamp_title(timestamp);
// Save WAV file
let file_path = self.recordings_dir.join(&file_name);
save_wav_file(file_path, &audio_samples).await?;
// Save to database
self.save_to_database(file_name, timestamp, title, transcription_text)?;
// Clean up old entries
self.cleanup_old_entries()?;
// Emit history updated event
if let Err(e) = self.app_handle.emit("history-updated", ()) {
error!("Failed to emit history-updated event: {}", e);
}
Ok(())
}
fn save_to_database(
&self,
file_name: String,
timestamp: i64,
title: String,
transcription_text: String,
) -> Result<()> {
let conn = self.get_connection()?;
conn.execute(
"INSERT INTO transcription_history (file_name, timestamp, saved, title, transcription_text) VALUES (?1, ?2, ?3, ?4, ?5)",
params![file_name, timestamp, false, title, transcription_text],
)?;
debug!("Saved transcription to database");
Ok(())
}
fn cleanup_old_entries(&self) -> Result<()> {
let conn = self.get_connection()?;
// Get all entries that are not saved, ordered by timestamp desc
let mut stmt = conn.prepare(
"SELECT id, file_name FROM transcription_history WHERE saved = 0 ORDER BY timestamp DESC"
)?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, i64>("id")?, row.get::<_, String>("file_name")?))
})?;
let mut entries: Vec<(i64, String)> = Vec::new();
for row in rows {
entries.push(row?);
}
if entries.len() > HISTORY_LIMIT {
let entries_to_delete = &entries[HISTORY_LIMIT..];
for (id, file_name) in entries_to_delete {
// Delete database entry
conn.execute(
"DELETE FROM transcription_history WHERE id = ?1",
params![id],
)?;
// Delete WAV file
let file_path = self.recordings_dir.join(file_name);
if file_path.exists() {
if let Err(e) = fs::remove_file(&file_path) {
error!("Failed to delete WAV file {}: {}", file_name, e);
} else {
debug!("Deleted old WAV file: {}", file_name);
}
}
}
debug!("Cleaned up {} old history entries", entries_to_delete.len());
}
Ok(())
}
pub async fn get_history_entries(&self) -> Result<Vec<HistoryEntry>> {
let conn = self.get_connection()?;
let mut stmt = conn.prepare(
"SELECT id, file_name, timestamp, saved, title, transcription_text FROM transcription_history ORDER BY timestamp DESC"
)?;
let rows = stmt.query_map([], |row| {
Ok(HistoryEntry {
id: row.get("id")?,
file_name: row.get("file_name")?,
timestamp: row.get("timestamp")?,
saved: row.get("saved")?,
title: row.get("title")?,
transcription_text: row.get("transcription_text")?,
})
})?;
let mut entries = Vec::new();
for row in rows {
entries.push(row?);
}
Ok(entries)
}
pub async fn toggle_saved_status(&self, id: i64) -> Result<()> {
let conn = self.get_connection()?;
// Get current saved status
let current_saved: bool = conn.query_row(
"SELECT saved FROM transcription_history WHERE id = ?1",
params![id],
|row| row.get("saved"),
)?;
let new_saved = !current_saved;
conn.execute(
"UPDATE transcription_history SET saved = ?1 WHERE id = ?2",
params![new_saved, id],
)?;
debug!("Toggled saved status for entry {}: {}", id, new_saved);
// Emit history updated event
if let Err(e) = self.app_handle.emit("history-updated", ()) {
error!("Failed to emit history-updated event: {}", e);
}
Ok(())
}
pub fn get_audio_file_path(&self, file_name: &str) -> PathBuf {
self.recordings_dir.join(file_name)
}
fn format_timestamp_title(&self, timestamp: i64) -> String {
if let Some(utc_datetime) = DateTime::from_timestamp(timestamp, 0) {
// Convert UTC to local timezone
let local_datetime = utc_datetime.with_timezone(&Local);
local_datetime.format("%B %e, %Y - %l:%M%p").to_string()
} else {
format!("Recording {}", timestamp)
}
}
}

View file

@ -1,3 +1,4 @@
pub mod audio;
pub mod history;
pub mod model;
pub mod transcription;

View file

@ -23,7 +23,11 @@
}
],
"security": {
"csp": null
"csp": null,
"assetProtocol": {
"enable": true,
"scope": ["**"]
}
}
},
"bundle": {

View file

@ -41,12 +41,12 @@ export const SECTIONS_CONFIG = {
component: AdvancedSettings,
enabled: () => true,
},
// history: {
// label: "History",
// icon: History,
// component: HistorySettings,
// enabled: () => true,
// },
history: {
label: "History",
icon: History,
component: HistorySettings,
enabled: () => true,
},
debug: {
label: "Debug",
icon: FlaskConical,

View file

@ -1,12 +1,197 @@
import React from "react";
import React, { useState, useEffect, useCallback } from "react";
import { SettingsGroup } from "../ui/SettingsGroup";
import { AudioPlayer } from "../ui/AudioPlayer";
import { ClipboardCopy, Star, Check } from "lucide-react";
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
interface HistoryEntry {
id: number;
file_name: string;
timestamp: number;
saved: boolean;
title: string;
transcription_text: string;
}
export const HistorySettings: React.FC = () => {
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const loadHistoryEntries = useCallback(async () => {
try {
const entries = await invoke<HistoryEntry[]>("get_history_entries");
setHistoryEntries(entries);
} catch (error) {
console.error("Failed to load history entries:", error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadHistoryEntries();
// Listen for history update events
const setupListener = async () => {
const unlisten = await listen("history-updated", () => {
console.log("History updated, reloading entries...");
loadHistoryEntries();
});
// Return cleanup function
return unlisten;
};
let unlistenPromise = setupListener();
return () => {
unlistenPromise.then((unlisten) => {
if (unlisten) {
unlisten();
}
});
};
}, [loadHistoryEntries]);
const toggleSaved = async (id: number) => {
try {
await invoke("toggle_history_entry_saved", { id });
// No need to reload here - the event listener will handle it
} catch (error) {
console.error("Failed to toggle saved status:", error);
}
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
}
};
const getAudioUrl = async (fileName: string) => {
try {
const filePath = await invoke<string>("get_audio_file_path", {
fileName,
});
return convertFileSrc(`${filePath}`, "asset");
} catch (error) {
console.error("Failed to get audio file path:", error);
return null;
}
};
if (loading) {
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="History">
<div className="px-4 py-3 text-center text-text/60">
Loading history...
</div>
</SettingsGroup>
</div>
);
}
if (historyEntries.length === 0) {
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="History">
<div className="px-4 py-3 text-center text-text/60">
No transcriptions yet. Start recording to build your history!
</div>
</SettingsGroup>
</div>
);
}
return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title="History">
<></>
{historyEntries.map((entry) => (
<HistoryEntryComponent
key={entry.id}
entry={entry}
onToggleSaved={() => toggleSaved(entry.id)}
onCopyText={() => copyToClipboard(entry.transcription_text)}
getAudioUrl={getAudioUrl}
/>
))}
</SettingsGroup>
</div>
);
};
interface HistoryEntryProps {
entry: HistoryEntry;
onToggleSaved: () => void;
onCopyText: () => void;
getAudioUrl: (fileName: string) => Promise<string | null>;
}
const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
entry,
onToggleSaved,
onCopyText,
getAudioUrl,
}) => {
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [showCopied, setShowCopied] = useState(false);
useEffect(() => {
const loadAudio = async () => {
const url = await getAudioUrl(entry.file_name);
setAudioUrl(url);
};
loadAudio();
}, [entry.file_name, getAudioUrl]);
const handleCopyText = () => {
onCopyText();
setShowCopied(true);
setTimeout(() => setShowCopied(false), 2000);
};
return (
<div className="px-4 py-2 pb-5 flex flex-col gap-3">
<div className="flex justify-between items-center">
<p className="text-sm font-medium">{entry.title}</p>
<div className="flex items-center gap-1">
<button
onClick={handleCopyText}
className="text-text/50 hover:text-logo-primary hover:border-logo-primary transition-colors cursor-pointer"
title="Copy transcription to clipboard"
>
{showCopied ? (
<Check width={16} height={16} />
) : (
<ClipboardCopy width={16} height={16} />
)}
</button>
<button
onClick={onToggleSaved}
className={`p-2 rounded transition-colors cursor-pointer ${
entry.saved
? "text-logo-primary hover:text-logo-primary/80"
: "text-text/50 hover:text-logo-primary"
}`}
title={entry.saved ? "Remove from saved" : "Save transcription"}
>
<Star
width={16}
height={16}
fill={entry.saved ? "currentColor" : "none"}
/>
</button>
</div>
</div>
<p className="italic text-text/90 text-sm pb-2">
{entry.transcription_text}
</p>
{audioUrl && <AudioPlayer src={audioUrl} className="w-full" />}
</div>
);
};

View file

@ -0,0 +1,220 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import { Play, Pause } from "lucide-react";
interface AudioPlayerProps {
src: string;
className?: string;
}
export const AudioPlayer: React.FC<AudioPlayerProps> = ({
src,
className = "",
}) => {
const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const audioRef = useRef<HTMLAudioElement>(null);
const animationRef = useRef<number>();
const dragTimeRef = useRef<number>(0);
// Use refs to avoid stale closures in animation loop
const isPlayingRef = useRef(false);
const isDraggingRef = useRef(false);
// Keep refs in sync with state
useEffect(() => {
isPlayingRef.current = isPlaying;
}, [isPlaying]);
useEffect(() => {
isDraggingRef.current = isDragging;
}, [isDragging]);
// Stable animation loop with no dependencies
const tick = useCallback(() => {
if (audioRef.current && !isDraggingRef.current) {
const time = audioRef.current.currentTime;
setCurrentTime(time);
}
if (isPlayingRef.current) {
animationRef.current = requestAnimationFrame(tick);
}
}, []); // Empty dependency array is key!
// Manage animation loop lifecycle
useEffect(() => {
if (isPlaying && !isDragging) {
// Only start if not already running
if (!animationRef.current) {
animationRef.current = requestAnimationFrame(tick);
}
} else {
// Stop animation loop
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = undefined;
}
}
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = undefined;
}
};
}, [isPlaying, isDragging, tick]);
// Audio event handlers
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const handleLoadedMetadata = () => {
setDuration(audio.duration || 0);
setCurrentTime(0);
};
const handleEnded = () => {
setIsPlaying(false);
setCurrentTime(audio.duration || 0);
};
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
audio.addEventListener("loadedmetadata", handleLoadedMetadata);
audio.addEventListener("ended", handleEnded);
audio.addEventListener("play", handlePlay);
audio.addEventListener("pause", handlePause);
return () => {
audio.removeEventListener("loadedmetadata", handleLoadedMetadata);
audio.removeEventListener("ended", handleEnded);
audio.removeEventListener("play", handlePlay);
audio.removeEventListener("pause", handlePause);
};
}, []);
// Global drag handlers
const handleMouseUp = useCallback(() => {
if (isDragging) {
setIsDragging(false);
if (audioRef.current) {
audioRef.current.currentTime = dragTimeRef.current;
setCurrentTime(dragTimeRef.current);
}
}
}, [isDragging]);
useEffect(() => {
if (isDragging) {
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("touchend", handleMouseUp);
return () => {
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("touchend", handleMouseUp);
};
}
}, [isDragging, handleMouseUp]);
const togglePlay = async () => {
const audio = audioRef.current;
if (!audio) return;
try {
if (isPlaying) {
audio.pause();
} else {
await audio.play();
}
} catch (error) {
console.error("Playback failed:", error);
}
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = parseFloat(e.target.value);
dragTimeRef.current = newTime;
setCurrentTime(newTime);
if (!isDragging && audioRef.current) {
audioRef.current.currentTime = newTime;
}
};
const handleSliderMouseDown = () => {
setIsDragging(true);
};
const handleSliderTouchStart = () => {
setIsDragging(true);
};
const formatTime = (time: number): string => {
if (!isFinite(time)) return "0:00";
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
// Fix playhead positioning with better edge case handling
const getProgressPercent = (): number => {
if (duration <= 0) return 0;
// Handle the end case - if we're within 0.1 seconds of the end, show 100%
if (duration - currentTime < 0.1) return 100;
const percent = (currentTime / duration) * 100;
return Math.min(100, Math.max(0, percent));
};
const progressPercent = getProgressPercent();
return (
<div className={`flex items-center gap-3 ${className}`}>
<audio ref={audioRef} src={src} preload="metadata" />
<button
onClick={togglePlay}
className="transition-colors cursor-pointer text-text hover:text-logo-primary"
aria-label={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? (
<Pause width={20} height={20} fill="currentColor" />
) : (
<Play width={20} height={20} fill="currentColor" />
)}
</button>
<div className="flex-1 flex items-center gap-2">
<span className="text-xs text-text/60 min-w-[30px] tabular-nums">
{formatTime(currentTime)}
</span>
<input
type="range"
min="0"
max={duration || 0}
step="0.01"
value={currentTime}
onChange={handleSeek}
onMouseDown={handleSliderMouseDown}
onTouchStart={handleSliderTouchStart}
className={`flex-1 h-1 rounded-lg appearance-none cursor-pointer focus:outline-none focus:ring-1 focus:ring-logo-primary ${progressPercent >= 99.5 ? "[&::-webkit-slider-thumb]:translate-x-0.5 [&::-moz-range-thumb]:translate-x-0.5" : ""}`}
style={{
background: `linear-gradient(to right, #FAA2CA 0%, #FAA2CA ${progressPercent}%, rgba(128, 128, 128, 0.2) ${progressPercent}%, rgba(128, 128, 128, 0.2) 100%)`,
}}
/>
<span className="text-xs text-text/60 min-w-[30px] tabular-nums">
{formatTime(duration)}
</span>
</div>
</div>
);
};