diff --git a/bun.lockb b/bun.lockb index 2e86f7e..08e5b1f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 5ac94dd..7855ffc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 81a058d..f45756b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 713be49..daee8f8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 560d2b4..6c7bc30 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -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::>()); let tm = Arc::clone(&app.state::>()); + let hm = Arc::clone(&app.state::>()); 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(); diff --git a/src-tauri/src/audio_toolkit/audio/mod.rs b/src-tauri/src/audio_toolkit/audio/mod.rs index 83e307c..c0abd7e 100644 --- a/src-tauri/src/audio_toolkit/audio/mod.rs +++ b/src-tauri/src/audio_toolkit/audio/mod.rs @@ -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; diff --git a/src-tauri/src/audio_toolkit/audio/utils.rs b/src-tauri/src/audio_toolkit/audio/utils.rs new file mode 100644 index 0000000..cec8be7 --- /dev/null +++ b/src-tauri/src/audio_toolkit/audio/utils.rs @@ -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>(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(()) +} diff --git a/src-tauri/src/audio_toolkit/mod.rs b/src-tauri/src/audio_toolkit/mod.rs index b9455a0..0f88bdc 100644 --- a/src-tauri/src/audio_toolkit/mod.rs +++ b/src-tauri/src/audio_toolkit/mod.rs @@ -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}; diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs new file mode 100644 index 0000000..6c4ee2d --- /dev/null +++ b/src-tauri/src/commands/history.rs @@ -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>, +) -> Result, 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>, + 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>, + file_name: String, +) -> Result { + 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()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 76b94fd..5c29081 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod audio; +pub mod history; pub mod models; pub mod transcription; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1a34a71..6ad6b06 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src-tauri/src/managers/history.rs b/src-tauri/src/managers/history.rs new file mode 100644 index 0000000..b524dc3 --- /dev/null +++ b/src-tauri/src/managers/history.rs @@ -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 { + 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 { + 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 { + 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, + 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> { + 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) + } + } +} diff --git a/src-tauri/src/managers/mod.rs b/src-tauri/src/managers/mod.rs index 73a52cf..1239dc2 100644 --- a/src-tauri/src/managers/mod.rs +++ b/src-tauri/src/managers/mod.rs @@ -1,3 +1,4 @@ pub mod audio; +pub mod history; pub mod model; pub mod transcription; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 904664e..66e546e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,11 @@ } ], "security": { - "csp": null + "csp": null, + "assetProtocol": { + "enable": true, + "scope": ["**"] + } } }, "bundle": { diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 5ae0d59..ef4e139 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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, diff --git a/src/components/settings/HistorySettings.tsx b/src/components/settings/HistorySettings.tsx index 87fbad0..8c9e018 100644 --- a/src/components/settings/HistorySettings.tsx +++ b/src/components/settings/HistorySettings.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + + const loadHistoryEntries = useCallback(async () => { + try { + const entries = await invoke("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("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 ( +
+ +
+ Loading history... +
+
+
+ ); + } + + if (historyEntries.length === 0) { + return ( +
+ +
+ No transcriptions yet. Start recording to build your history! +
+
+
+ ); + } + return (
- <> + {historyEntries.map((entry) => ( + toggleSaved(entry.id)} + onCopyText={() => copyToClipboard(entry.transcription_text)} + getAudioUrl={getAudioUrl} + /> + ))}
); }; + +interface HistoryEntryProps { + entry: HistoryEntry; + onToggleSaved: () => void; + onCopyText: () => void; + getAudioUrl: (fileName: string) => Promise; +} + +const HistoryEntryComponent: React.FC = ({ + entry, + onToggleSaved, + onCopyText, + getAudioUrl, +}) => { + const [audioUrl, setAudioUrl] = useState(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 ( +
+
+

{entry.title}

+
+ + +
+
+

+ {entry.transcription_text} +

+ {audioUrl && } +
+ ); +}; diff --git a/src/components/ui/AudioPlayer.tsx b/src/components/ui/AudioPlayer.tsx new file mode 100644 index 0000000..0b2e9f8 --- /dev/null +++ b/src/components/ui/AudioPlayer.tsx @@ -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 = ({ + 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(null); + const animationRef = useRef(); + const dragTimeRef = useRef(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) => { + 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 ( +
+
+ ); +};