diff --git a/package-lock.json b/package-lock.json index e150a5f22..8479bd036 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "iptvnator", - "version": "1.0.0-beta.2", + "version": "1.0.0-beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "iptvnator", - "version": "1.0.0-beta.2", + "version": "1.0.0-beta.3", "dependencies": { "@angular-builders/jest": "17.0.0", "@angular/animations": "18.2.1", @@ -16,6 +16,7 @@ "@ngrx/effects": "18.0.2", "@ngrx/entity": "18.0.2", "@ngrx/router-store": "18.0.2", + "@ngrx/signals": "18.1.1", "@ngrx/store": "18.0.2", "@ngrx/store-devtools": "18.0.2", "@ngx-pwa/local-storage": "18.0.0", @@ -39,7 +40,6 @@ "iptv-playlist-parser": "github:4gray/iptv-playlist-parser", "lodash": "4.17.21", "moment": "2.30.1", - "nedb-promises": "6.0.3", "ngx-indexed-db": "19.0.0", "ngx-skeleton-loader": "9.0.0", "ngx-whats-new": "0.4.0", @@ -5656,6 +5656,23 @@ "rxjs": "^6.5.3 || ^7.5.0" } }, + "node_modules/@ngrx/signals": { + "version": "18.1.1", + "resolved": "https://registry.npmjs.org/@ngrx/signals/-/signals-18.1.1.tgz", + "integrity": "sha512-7AaFp4VrzYhnDHlF15VZAMvp067zIC5arRRRMV+i+oi95sQVDXezpj74KTIguwRbZ3FU+GKGzFCNrwgYvginSg==", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": "^18.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, "node_modules/@ngrx/store": { "version": "18.0.2", "resolved": "https://registry.npmjs.org/@ngrx/store/-/store-18.0.2.tgz", @@ -6615,26 +6632,6 @@ } } }, - "node_modules/@seald-io/binary-search-tree": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@seald-io/binary-search-tree/-/binary-search-tree-1.0.3.tgz", - "integrity": "sha512-qv3jnwoakeax2razYaMsGI/luWdliBLHTdC6jU55hQt1hcFqzauH/HsBollQ7IR4ySTtYhT+xyHoijpA16C+tA==" - }, - "node_modules/@seald-io/nedb": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@seald-io/nedb/-/nedb-2.2.2.tgz", - "integrity": "sha512-z91J3pem4ENzHuu9BilOSdlGL2S14OQYePPdvBcPHgHr+s51VIUwQARcOjx21KvtkA27vEpgPqzrVKh7nSlIfw==", - "dependencies": { - "@seald-io/binary-search-tree": "^1.0.2", - "async": "0.2.10", - "localforage": "^1.9.0" - } - }, - "node_modules/@seald-io/nedb/node_modules/async": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", - "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" - }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -14843,11 +14840,6 @@ "node": ">=0.10.0" } }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" - }, "node_modules/immutable": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", @@ -17897,14 +17889,6 @@ } } }, - "node_modules/lie": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", - "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", - "dependencies": { - "immediate": "~3.0.5" - } - }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -18143,14 +18127,6 @@ "node": ">= 12.13.0" } }, - "node_modules/localforage": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", - "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", - "dependencies": { - "lie": "3.1.1" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -19290,14 +19266,6 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, - "node_modules/nedb-promises": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/nedb-promises/-/nedb-promises-6.0.3.tgz", - "integrity": "sha512-pd0N6EsIPhXsfqdLcBJGig1FoTdV8wtuiXB7rBUqycJdUSA6wdI5BlkwEAjDBWIwZc4+xqBHRLtuAB7/bjS+rg==", - "dependencies": { - "@seald-io/nedb": "^2.2.0" - } - }, "node_modules/needle": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/needle/-/needle-3.3.1.tgz", diff --git a/package.json b/package.json index 90bc40c39..577e51455 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iptvnator", - "version": "1.0.0-beta.2", + "version": "1.0.0-beta.3", "description": "IPTV player application.", "homepage": "https://github.com/4gray/iptvnator", "author": { @@ -43,6 +43,7 @@ "@ngrx/effects": "18.0.2", "@ngrx/entity": "18.0.2", "@ngrx/router-store": "18.0.2", + "@ngrx/signals": "18.1.1", "@ngrx/store": "18.0.2", "@ngrx/store-devtools": "18.0.2", "@ngx-pwa/local-storage": "18.0.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c756383f1..7dd691e4d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4,10 +4,14 @@ version = 3 [[package]] name = "IPTVnator" -version = "1.0.0-beta.2" +version = "1.0.0-beta.3" dependencies = [ + "chrono", "fix-path-env", + "flate2", "log", + "quick-xml 0.31.0", + "reqwest 0.11.27", "serde", "serde_json", "tauri", @@ -21,6 +25,8 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-updater", "tauri-plugin-window-state", + "tokio", + "xmltv", ] [[package]] @@ -159,6 +165,19 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df895a515f70646414f4b45c0b79082783b80552b373a68283012928df56f522" +dependencies = [ + "flate2", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-recursion" version = "1.1.1" @@ -528,8 +547,10 @@ checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-targets 0.52.6", ] @@ -544,7 +565,7 @@ dependencies = [ "cocoa-foundation", "core-foundation 0.10.0", "core-graphics", - "foreign-types", + "foreign-types 0.5.0", "libc", "objc", ] @@ -651,7 +672,7 @@ dependencies = [ "bitflags 2.6.0", "core-foundation 0.10.0", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -954,7 +975,7 @@ dependencies = [ "rustc_version", "toml 0.8.2", "vswhom", - "winreg", + "winreg 0.52.0", ] [[package]] @@ -1137,6 +1158,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1144,7 +1174,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1158,6 +1188,12 @@ dependencies = [ "syn 2.0.86", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1565,6 +1601,25 @@ dependencies = [ "syn 2.0.86", ] +[[package]] +name = "h2" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.6.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.6" @@ -1576,7 +1631,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.1.0", "indexmap 2.6.0", "slab", "tokio", @@ -1646,6 +1701,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa 1.0.11", +] + [[package]] name = "http" version = "1.1.0" @@ -1657,6 +1723,17 @@ dependencies = [ "itoa 1.0.11", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1664,7 +1741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.1.0", ] [[package]] @@ -1675,8 +1752,8 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http", - "http-body", + "http 1.1.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1686,6 +1763,36 @@ version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c08302e8fa335b151b788c775ff56e7a03ae64ff85c548ee820fecb70356e85" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.26", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa 1.0.11", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.5.0" @@ -1695,9 +1802,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.6", + "http 1.1.0", + "http-body 1.0.1", "httparse", "itoa 1.0.11", "pin-project-lite", @@ -1713,8 +1820,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" dependencies = [ "futures-util", - "http", - "hyper", + "http 1.1.0", + "hyper 1.5.0", "hyper-util", "rustls", "rustls-pki-types", @@ -1724,6 +1831,19 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.31", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "hyper-util" version = "0.1.10" @@ -1733,9 +1853,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.1.0", + "http-body 1.0.1", + "hyper 1.5.0", "pin-project-lite", "socket2", "tokio", @@ -2188,6 +2308,23 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "native-tls" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2273,7 +2410,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 3.2.0", "proc-macro2", "quote", "syn 2.0.86", @@ -2542,6 +2679,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" +dependencies = [ + "bitflags 2.6.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.86", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2947,6 +3128,16 @@ dependencies = [ "psl-types", ] +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quick-xml" version = "0.32.0" @@ -3174,6 +3365,48 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "async-compression", + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.26", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.31", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg 0.50.0", +] + [[package]] name = "reqwest" version = "0.12.9" @@ -3187,11 +3420,11 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.6", + "http 1.1.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.5.0", "hyper-rustls", "hyper-util", "ipnet", @@ -3203,13 +3436,13 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", - "rustls-pemfile", + "rustls-pemfile 2.2.0", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", - "system-configuration", + "sync_wrapper 1.0.1", + "system-configuration 0.6.1", "tokio", "tokio-rustls", "tokio-util", @@ -3354,6 +3587,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -3395,6 +3637,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "schemars" version = "0.8.21" @@ -3440,6 +3691,29 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.6.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa39c7303dc58b5543c94d22c1766b0d31f2ee58306363ea622b10bbc075eaa2" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.22.0" @@ -3705,7 +3979,7 @@ dependencies = [ "bytemuck", "cfg_aliases 0.2.1", "core-graphics", - "foreign-types", + "foreign-types 0.5.0", "js-sys", "log", "objc2", @@ -3854,6 +4128,12 @@ dependencies = [ "syn 2.0.86", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.1" @@ -3872,6 +4152,17 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys 0.5.0", +] + [[package]] name = "system-configuration" version = "0.6.1" @@ -3880,7 +4171,17 @@ checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.6.0", "core-foundation 0.9.4", - "system-configuration-sys", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -3995,7 +4296,7 @@ dependencies = [ "glob", "gtk", "heck 0.5.0", - "http", + "http 1.1.0", "jni", "libc", "log", @@ -4007,7 +4308,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.12.9", "serde", "serde_json", "serde_repr", @@ -4155,9 +4456,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c752aee1b00ec3c4d4f440095995d9bd2c640b478f2067d1fba388900b82eb96" dependencies = [ "data-url", - "http", + "http 1.1.0", "regex", - "reqwest", + "reqwest 0.12.9", "schemars", "serde", "serde_json", @@ -4251,11 +4552,11 @@ dependencies = [ "dirs", "flate2", "futures-util", - "http", + "http 1.1.0", "infer", "minisign-verify", "percent-encoding", - "reqwest", + "reqwest 0.12.9", "semver", "serde", "serde_json", @@ -4294,7 +4595,7 @@ checksum = "a1ef7363e7229ac8d04e8a5d405670dbd43dde8fc4bc3bc56105c35452d03784" dependencies = [ "dpi", "gtk", - "http", + "http 1.1.0", "jni", "raw-window-handle", "serde", @@ -4312,7 +4613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62fa2068e8498ad007b54d5773d03d57c3ff6dd96f8c8ce58beff44d0d5e0d30" dependencies = [ "gtk", - "http", + "http 1.1.0", "jni", "log", "objc2", @@ -4485,6 +4786,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -4504,6 +4806,16 @@ dependencies = [ "syn 2.0.86", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.0" @@ -4817,6 +5129,12 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ef4c4aa54d5d05a279399bfa921ec387b7aba77caf7a682ae8d86785b8fdad2" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.0" @@ -5509,6 +5827,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.52.0" @@ -5533,7 +5861,7 @@ dependencies = [ "gdkx11", "gtk", "html5ever", - "http", + "http 1.1.0", "javascriptcore-rs", "jni", "kuchikiki", @@ -5611,6 +5939,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "xmltv" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe9c95973cc08e1ebbc63705f61643d63a72614dd42d4c33f0e789da5c96b3d" +dependencies = [ + "serde", +] + [[package]] name = "zbus" version = "5.1.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a9003ecd0..4d1397eb1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "IPTVnator" -version = "1.0.0-beta.2" +version = "1.0.0-beta.3" description = "IPTVnator" authors = ["4gray"] license = "MIT" @@ -21,7 +21,7 @@ tauri-build = { version = "2.0.1", features = [] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" -tauri = { version = "2.0.6", features = [] } +tauri = { version = "2.0.6", features = ["macos-private-api"] } tauri-plugin-log = "2.0.0-rc" tauri-plugin-http = {version = "2", features = ["unsafe-headers"] } tauri-plugin-shell = "2" @@ -30,6 +30,12 @@ tauri-plugin-os = "2" tauri-plugin-fs = "2" tauri-plugin-process = "2" tauri-plugin-dialog = "2" +xmltv = "0.9" +tokio = { version = "1.0", features = ["full"] } +chrono = { version = "0.4", features = ["serde"] } +reqwest = { version = "0.11", features = ["json", "gzip"] } +flate2 = "1.0" +quick-xml = { version = "0.31", features = ["serialize"] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index fcd9eeb76..e1246c7be 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -39,6 +39,7 @@ "fs:default", "updater:default", "dialog:default", - "process:default" + "process:default", + "core:window:allow-set-fullscreen" ] } diff --git a/src-tauri/src/epg.rs b/src-tauri/src/epg.rs new file mode 100644 index 000000000..4912702c0 --- /dev/null +++ b/src-tauri/src/epg.rs @@ -0,0 +1,235 @@ +use chrono::{DateTime, Utc}; +use flate2::read::GzDecoder; +use log::{info, warn}; +use quick_xml::de::from_str; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::Read; +use std::sync::Mutex; +use tokio::sync::OnceCell; +use xmltv::Tv; + +// Modified to store multiple EPG sources +#[derive(Debug)] +struct EpgStore { + sources: Vec, +} + +#[derive(Debug)] +struct EpgSource { + url: String, + programs: HashMap>, +} + +// Replace the static EPG_DATA with the new structure +static EPG_DATA: OnceCell>> = OnceCell::const_new(); + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Program { + pub start: DateTime, + pub stop: DateTime, + pub title: String, + pub desc: Option, + pub channel: String, + pub category: Option, + pub icon: Option, + pub language: Option, +} + +fn parse_xmltv_datetime(datetime: &str) -> Option> { + DateTime::parse_from_str(datetime, "%Y%m%d%H%M%S %z") + .ok() + .map(|dt| dt.with_timezone(&Utc)) +} + +pub async fn fetch_and_parse_epg(url: String) -> Result<(), Box> { + info!("Starting EPG fetch from URL: {}", url); + + let new_source = fetch_single_epg_source(url.clone()).await?; + + // Update EPG store + match EPG_DATA.get() { + Some(epg_data) => { + let mut guard = epg_data.lock().unwrap(); + let store = guard.get_or_insert_with(|| EpgStore { + sources: Vec::new(), + }); + + // Remove existing source with same URL if exists + store.sources.retain(|source| source.url != url); + // Add new source + store.sources.push(new_source); + info!("Updated EPG store for URL: {}", url); + } + None => { + EPG_DATA + .set(Mutex::new(Some(EpgStore { + sources: vec![new_source], + }))) + .unwrap(); + info!("Created new EPG data store"); + } + } + + Ok(()) +} + +// New helper function to fetch and parse a single EPG source +async fn fetch_single_epg_source(url: String) -> Result> { + info!("Starting EPG fetch from URL: {}", url); + + // Fetch content + let client = reqwest::Client::new(); + let content = if url.ends_with(".gz") { + info!("Fetching and decompressing gzipped EPG data..."); + let bytes = client.get(&url).send().await?.bytes().await?; + let mut decoder = GzDecoder::new(&bytes[..]); + let mut string = String::new(); + decoder.read_to_string(&mut string)?; + info!("Successfully decompressed EPG data"); + string + } else { + info!("Fetching EPG data..."); + client.get(&url).send().await?.text().await? + }; + + info!("Parsing EPG XML data..."); + let tv: Tv = from_str(&content)?; + + // Get channel name mapping + let channel_names: HashMap = tv + .channels + .into_iter() + .filter_map(|channel| { + channel + .display_names + .first() + .map(|name| (channel.id, name.name.clone())) + }) + .collect(); + + info!("Converting EPG programs..."); + let programs: Vec = tv + .programmes + .into_iter() + .filter_map(|p| { + let channel_name = match channel_names.get(&p.channel) { + Some(name) => name.clone(), + None => { + warn!("No channel name found for id: {}", p.channel); + return None; + } + }; + + let parse_datetime = |opt_str: &Option| -> Option> { + opt_str.as_deref().and_then(parse_xmltv_datetime) + }; + + let start = match parse_datetime(&Some(p.start)) { + Some(dt) => dt, + None => { + warn!( + "Invalid start time format for program in channel {}", + p.channel + ); + return None; + } + }; + + let stop = match parse_datetime(&p.stop) { + Some(dt) => dt, + None => { + warn!( + "Invalid stop time format for program in channel {}", + p.channel + ); + return None; + } + }; + + let title = match p.titles.first() { + Some(t) => t.value.clone(), + None => { + warn!("Program without title found in channel {}", p.channel); + return None; + } + }; + + Some(Program { + start, + stop, + title, + desc: p.descriptions.first().map(|d| d.value.clone()), + channel: channel_name, + category: p.categories.first().map(|c| c.name.clone()), + icon: p.icons.first().map(|i| i.src.clone()), + language: p.language.map(|lang| lang.value), + }) + }) + .collect(); + + info!("Successfully parsed {} EPG programs", programs.len()); + + // Group by channel + let program_map = programs + .into_iter() + .fold(HashMap::new(), |mut acc, program| { + acc.entry(program.channel.clone()) + .or_insert_with(Vec::new) + .push(program); + acc + }); + + info!("EPG data grouped into {} channels", program_map.len()); + + Ok(EpgSource { + url, + programs: program_map, + }) +} + +pub async fn get_programs_by_channel( + channel_name: String, +) -> Result, Box> { + info!("Retrieving programs for channel name: {}", channel_name); + + let programs = match EPG_DATA.get() { + Some(data) => { + let guard = data.lock().unwrap(); + match &*guard { + Some(store) => { + // Search through all sources and combine matches + let mut all_programs = Vec::new(); + for source in &store.sources { + if let Some(programs) = source.programs.get(&channel_name) { + info!( + "Found {} programs for channel {} in source {}", + programs.len(), + channel_name, + source.url + ); + all_programs.extend(programs.clone()); + } + } + // Sort programs by start time + all_programs.sort_by(|a, b| a.start.cmp(&b.start)); + all_programs + } + None => Vec::new(), + } + } + None => Vec::new(), + }; + + if programs.is_empty() { + warn!("No programs found for channel name: {}", channel_name); + } else { + info!( + "Found {} total programs for channel {}", + programs.len(), + channel_name + ); + } + + Ok(programs) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3151cf083..6a0ccd723 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -78,6 +78,26 @@ async fn open_in_vlc(url: String, path: String) -> Result<(), String> { Ok(()) } +mod epg; +use epg::get_programs_by_channel; + +#[tauri::command] +async fn fetch_epg(url: Vec) -> Result<(), String> { + for single_url in url { + epg::fetch_and_parse_epg(single_url) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +#[tauri::command] +async fn get_channel_programs(channel_id: String) -> Result, String> { + get_programs_by_channel(channel_id) + .await + .map_err(|e| e.to_string()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -89,7 +109,12 @@ pub fn run() { .plugin(tauri_plugin_window_state::Builder::new().build()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_http::init()) - .invoke_handler(tauri::generate_handler![open_in_mpv, open_in_vlc]) + .invoke_handler(tauri::generate_handler![ + fetch_epg, + get_channel_programs, + open_in_mpv, + open_in_vlc + ]) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4e39916e2..5522a2272 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "iptvnator", - "version": "1.0.0-2", + "version": "1.0.0-3", "identifier": "com.4gray.dev", "build": { "frontendDist": "../dist", @@ -28,6 +28,7 @@ "titleBarStyle": "Transparent" } ], + "macOSPrivateApi": true, "security": { "csp": { "default-src": [ diff --git a/src/app/app.component.ts b/src/app/app.component.ts index a2a9c59e8..d9123d77d 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -12,8 +12,6 @@ import * as semver from 'semver'; import { IpcCommand } from '../../shared/ipc-command.class'; import { AUTO_UPDATE_PLAYLISTS, - EPG_ERROR, - EPG_FETCH_DONE, ERROR, OPEN_FILE, SETTINGS_UPDATE, @@ -50,8 +48,6 @@ export class AppComponent { commandsList = [ new IpcCommand(VIEW_ADD_PLAYLIST, () => this.navigateToRoute('/')), new IpcCommand(VIEW_SETTINGS, () => this.navigateToRoute('/settings')), - new IpcCommand(EPG_FETCH_DONE, () => this.epgService.onEpgFetchDone()), - new IpcCommand(EPG_ERROR, () => this.epgService.onEpgError()), new IpcCommand(SHOW_WHATS_NEW, () => this.showWhatsNewDialog()), new IpcCommand(ERROR, (response: { message: string; status: number }) => this.showErrorAsNotification(response) diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 18aa11225..e570de0ca 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -48,17 +48,14 @@ function isTauri() { /** * Conditionally imports the necessary service based on the current environment */ -export function DataFactory( - ngxIndexedDBService: NgxIndexedDBService, - http: HttpClient -) { +export function DataFactory() { if (isElectron()) { return new ElectronService(); } if (isTauri()) { return new TauriService(); } - return new PwaService(http); + return new PwaService(); } @NgModule({ diff --git a/src/app/player/components/channel-list-container/channel-list-container.component.html b/src/app/player/components/channel-list-container/channel-list-container.component.html index 746a1ff2b..ac4539969 100644 --- a/src/app/player/components/channel-list-container/channel-list-container.component.html +++ b/src/app/player/components/channel-list-container/channel-list-container.component.html @@ -13,6 +13,8 @@ matInput class="full-width" type="search" + spellcheck="false" + autocomplete="off" #search [placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate" [(ngModel)]="searchTerm.name" @@ -55,100 +57,105 @@ - - - - {{ - groups.key || - ('CHANNELS.UNGROUPED' | translate) - | titlecase - }} - ({{ groups.value.length }}) - - - - - - - - + > + + + + + } + } - - - star -
- {{ 'CHANNELS.FAVORITES' | translate }} -
-
- - - - - - {{ - 'CHANNELS.NO_FAVORITES' | translate - }} - - {{ - 'CHANNELS.USE_STAR_TO_FAVORITE' | translate - }} + @let showFavorites = (playlistId$ | async) !== 'GLOBAL_FAVORITES'; + @if (showFavorites) { + + + star +
+ {{ 'CHANNELS.FAVORITES' | translate }} +
-
-
+ @let favorites = favorites$ | async; + @if (favorites) { + + @if (favorites.length > 0) { + + } @else { + {{ + 'CHANNELS.NO_FAVORITES' | translate + }} + + {{ + 'CHANNELS.USE_STAR_TO_FAVORITE' | translate + }} + } + + } + + } } @else { No channels there diff --git a/src/app/player/components/channel-list-container/channel-list-container.component.ts b/src/app/player/components/channel-list-container/channel-list-container.component.ts index 7bfba83c2..12d959cf0 100644 --- a/src/app/player/components/channel-list-container/channel-list-container.component.ts +++ b/src/app/player/components/channel-list-container/channel-list-container.component.ts @@ -1,4 +1,8 @@ -import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; +import { + CdkDragDrop, + DragDropModule, + moveItemInArray, +} from '@angular/cdk/drag-drop'; import { ScrollingModule } from '@angular/cdk/scrolling'; import { CommonModule, TitleCasePipe } from '@angular/common'; import { @@ -22,6 +26,7 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core'; import * as _ from 'lodash'; import { map, skipWhile } from 'rxjs'; import { Channel } from '../../../../../shared/channel.interface'; +import { EpgService } from '../../../services/epg.service'; import { FilterPipe } from '../../../shared/pipes/filter.pipe'; import * as PlaylistActions from '../../../state/actions'; import { @@ -36,8 +41,11 @@ import { ChannelListItemComponent } from './channel-list-item/channel-list-item. templateUrl: './channel-list-container.component.html', styleUrls: ['./channel-list-container.component.scss'], imports: [ - CommonModule, ChannelListItemComponent, + CommonModule, + DragDropModule, + FilterPipe, + FormsModule, MatDividerModule, MatExpansionModule, MatFormFieldModule, @@ -45,11 +53,9 @@ import { ChannelListItemComponent } from './channel-list-item/channel-list-item. MatInputModule, MatListModule, MatTabsModule, - TranslateModule, - TitleCasePipe, - FilterPipe, - FormsModule, ScrollingModule, + TitleCasePipe, + TranslateModule, ], }) export class ChannelListContainerComponent { @@ -116,7 +122,8 @@ export class ChannelListContainerComponent { constructor( private readonly store: Store, private snackBar: MatSnackBar, - private translateService: TranslateService + private translateService: TranslateService, + private epgService: EpgService ) {} /** @@ -126,6 +133,12 @@ export class ChannelListContainerComponent { selectChannel(channel: Channel): void { this.selected = channel; this.store.dispatch(PlaylistActions.setActiveChannel({ channel })); + + const epgChannelId = channel?.tvg?.id || channel?.name; + + if (epgChannelId) { + this.epgService.getChannelPrograms(epgChannelId); + } } /** diff --git a/src/app/player/components/epg-list/epg-item-description/epg-item-description.component.html b/src/app/player/components/epg-list/epg-item-description/epg-item-description.component.html index 297a15d4f..73fbe21b2 100644 --- a/src/app/player/components/epg-list/epg-item-description/epg-item-description.component.html +++ b/src/app/player/components/epg-list/epg-item-description/epg-item-description.component.html @@ -6,7 +6,7 @@
{{ 'EPG.PROGRAM_DIALOG.TITLE' | translate }}
-

{{ epgProgram.title[0].value }}

+

{{ epgProgram.title }}

{{ 'EPG.PROGRAM_DIALOG.LANGUAGE' | translate }}
@@ -16,13 +16,13 @@
{{ 'EPG.PROGRAM_DIALOG.CATEGORY' | translate }}
-

{{ epgProgram.category[0].value }}

+

{{ epgProgram.category }}

{{ 'EPG.PROGRAM_DIALOG.DESCRIPTION' | translate }}
-

{{ epgProgram.desc[0].value }}

+

{{ epgProgram.desc }}

diff --git a/src/app/player/components/epg-list/epg-list-item/epg-list-item.component.html b/src/app/player/components/epg-list/epg-list-item/epg-list-item.component.html index 2f30b200e..d06100f57 100644 --- a/src/app/player/components/epg-list/epg-list-item/epg-list-item.component.html +++ b/src/app/player/components/epg-list/epg-list-item/epg-list-item.component.html @@ -17,7 +17,7 @@ > } -@if (item.desc.length > 0) { +@if (item.desc?.length > 0) { -
-
-
- @if (channel?.icon) { - - } -
-
-
- {{ channel?.name[0]?.value }} -
-
- {{ - playingNow - ? playingNow.title[0]?.value - : ('EPG.LIVE_STREAM' | translate) - }} -
-
+@let items = filteredItems$ | async; +@let timeshiftUntil = timeshiftUntil$ | async; + + +
+
+
+ @if (channel?.icon) { + + }
- -
- - - {{ dateToday | momentDate: 'YYYYMMDD' : 'MMMM Do, dddd' }} - +
+
+ {{ channel?.name }} +
+
+ {{ + playingNow + ? playingNow.title + : ('EPG.LIVE_STREAM' | translate) + }} +
- - @let timeshiftUntil = timeshiftUntil$ | async; - @if (timeshiftUntil) { - - + + + {{ dateToday | momentDate: 'YYYY-MM-DD' : 'MMMM Do, dddd' }} + +
+
+ + +@if (timeshiftUntil) { + + + @if (items?.length > 0) { + @for (program of items; track program) { + @if ( + program.start < timeshiftUntil || program.start >= timeNow + ) { + +
+ +
+

{{ program?.title }}

+
+ } @else {
-

{{ item?.title[0]?.value }}

+

- - -
- -
-

-
-
- - - } @else { -

- {{ 'EPG.EPG_NOT_AVAILABLE_DATE' | translate }} -

+ } + } -
- } -} @else { - - - {{ 'EPG.EPG_NOT_AVAILABLE_CHANNEL_TITLE' | translate }}
- {{ 'EPG.EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION' | translate }} -
-
+ } @else { +

+ {{ 'EPG.EPG_NOT_AVAILABLE_DATE' | translate }} +

+ } + } diff --git a/src/app/player/components/epg-list/epg-list.component.scss b/src/app/player/components/epg-list/epg-list.component.scss index 6a89462c1..23848830d 100644 --- a/src/app/player/components/epg-list/epg-list.component.scss +++ b/src/app/player/components/epg-list/epg-list.component.scss @@ -7,6 +7,7 @@ left: 0; bottom: 0; height: 100%; + overflow-x: hidden; } .active { diff --git a/src/app/player/components/epg-list/epg-list.component.ts b/src/app/player/components/epg-list/epg-list.component.ts index 69111be17..9dbe62359 100644 --- a/src/app/player/components/epg-list/epg-list.component.ts +++ b/src/app/player/components/epg-list/epg-list.component.ts @@ -1,5 +1,5 @@ import { AsyncPipe } from '@angular/common'; -import { Component, NgZone } from '@angular/core'; +import { Component } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatDividerModule } from '@angular/material/divider'; @@ -9,10 +9,11 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { Store } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; import moment from 'moment'; -import { Observable } from 'rxjs'; +import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { EPG_GET_PROGRAM_DONE } from '../../../../../shared/ipc-commands'; import { DataService } from '../../../services/data.service'; +import { EpgService } from '../../../services/epg.service'; import { MomentDatePipe } from '../../../shared/pipes/moment-date.pipe'; import { resetActiveEpgProgram, @@ -29,8 +30,7 @@ export interface EpgData { items: EpgProgram[]; } -const DATE_FORMAT = 'YYYYMMDD'; -const DATE_TIME_FORMAT = 'YYYYMMDDHHmm ZZ'; +const DATE_FORMAT = 'YYYY-MM-DD'; @Component({ standalone: true, @@ -58,7 +58,7 @@ export class EpgListComponent { dateToday: string; /** Array with EPG programs */ - items: EpgProgram[] = []; + items$ = this.epgService.currentEpgPrograms$; /** Object with epg programs for the active channel */ programs: { @@ -77,61 +77,65 @@ export class EpgListComponent { /** Timeshift availability date, based on tvg-rec value from the channel */ timeshiftUntil$: Observable; - /** - * Creates an instance of EpgListComponent - * @param store - * @param electronService - * @param ngZone - */ + private readonly selectedDate$ = new BehaviorSubject( + moment().format(DATE_FORMAT) + ); + + /** Filtered EPG programs based on selected date */ + filteredItems$ = combineLatest([this.items$, this.selectedDate$]).pipe( + map(([items, selectedDate]) => + items + .filter( + (item) => + moment(item.start).format('YYYY-MM-DD') === selectedDate + ) + .sort((a, b) => moment(a.start).diff(moment(b.start))) + ) + ); + constructor( private readonly store: Store, - private electronService: DataService, - private ngZone: NgZone - ) { - this.electronService.listenOn( - EPG_GET_PROGRAM_DONE, - (event, response) => { - this.ngZone.run(() => this.handleEpgData(response)); - } - ); - } + private dataService: DataService, + private readonly epgService: EpgService + ) {} /** * Subscribe for values from the store on component init */ ngOnInit(): void { this.timeshiftUntil$ = this.store.select(selectActive).pipe( - // eslint-disable-next-line @ngrx/avoid-mapping-selectors map((active) => { + console.log(active); + this.channel = { + id: active?.tvg?.id, + name: active?.name, + url: [active?.url], + icon: [active?.tvg?.logo], + }; return ( active?.tvg?.rec || active?.timeshift || active?.catchup?.days ); }), - map((value) => - moment(Date.now()) - .subtract(value, 'days') - .format(DATE_TIME_FORMAT) - ) + map((value) => moment().subtract(value, 'days').toISOString()) ); + + this.items$.subscribe((programs) => this.handleEpgData(programs)); + this.dateToday = moment().format(DATE_FORMAT); + this.selectedDate$.next(this.dateToday); } /** * Handles incoming epg programs for the active channel from the main process * @param programs */ - handleEpgData(programs: { payload: EpgData }): void { - if (programs?.payload?.items?.length > 0) { - this.programs = programs; - this.timeNow = moment(Date.now()).format(DATE_TIME_FORMAT); - this.dateToday = moment(Date.now()).format(DATE_FORMAT); - this.channel = programs.payload?.channel; - this.items = this.selectPrograms(programs); - + handleEpgData(programs: EpgProgram[]): void { + this.timeNow = new Date().toISOString(); + this.dateToday = moment().format(DATE_FORMAT); + if (programs.length > 0) { this.setPlayingNow(); } else { - this.items = []; this.channel = null; this.store.dispatch(setCurrentEpgProgram(undefined)); } @@ -139,23 +143,20 @@ export class EpgListComponent { /** * Selects the program based on the active date - * @param programs object with all available epg programs for the active channel */ selectPrograms(programs: { payload: EpgData }): EpgProgram[] { + const selectedDate = moment(this.dateToday).format('YYYY-MM-DD'); return programs.payload?.items - .filter((item) => item.start.includes(this.dateToday.toString())) + .filter( + (item) => + moment(item.start).format('YYYY-MM-DD') === selectedDate + ) .map((program) => ({ ...program, - start: moment(program.start, DATE_TIME_FORMAT).format( - DATE_TIME_FORMAT - ), - stop: moment(program.stop, DATE_TIME_FORMAT).format( - DATE_TIME_FORMAT - ), + start: program.start, // Keep ISO format + stop: program.stop, // Keep ISO format })) - .sort((a, b) => { - return a.start.localeCompare(b.start); - }); + .sort((a, b) => moment(a.start).diff(moment(b.start))); } /** @@ -163,28 +164,37 @@ export class EpgListComponent { * @param direction direction to switch */ changeDate(direction: 'next' | 'prev'): void { - let dateToSwitch; - if (direction === 'next') { - dateToSwitch = moment(this.dateToday, DATE_FORMAT) - .add(1, 'days') - .format(DATE_FORMAT); - } else if (direction === 'prev') { - dateToSwitch = moment(this.dateToday, DATE_FORMAT) - .subtract(1, 'days') - .format(DATE_FORMAT); - } - this.dateToday = dateToSwitch; - this.items = this.selectPrograms(this.programs); + const newDate = moment(this.selectedDate$.value) + [direction === 'next' ? 'add' : 'subtract'](1, 'days') + .format(DATE_FORMAT); + + this.dateToday = newDate; + this.selectedDate$.next(newDate); } /** * Sets the playing now variable based on the current time */ setPlayingNow(): void { - this.playingNow = this.items.find( - (item) => this.timeNow >= item.start && this.timeNow <= item.stop - ); - this.store.dispatch(setCurrentEpgProgram({ program: this.playingNow })); + this.items$ + .pipe( + map((items) => + items.find((item) => { + const now = new Date().toISOString(); + const start = new Date(item.start).toISOString(); + const stop = new Date(item.stop).toISOString(); + return now >= start && now <= stop; + }) + ) + ) + .subscribe((playingNow) => { + this.playingNow = playingNow; + if (playingNow) { + this.store.dispatch( + setCurrentEpgProgram({ program: playingNow }) + ); + } + }); } /** @@ -211,6 +221,6 @@ export class EpgListComponent { * Removes all ipc renderer listeners after destroy */ ngOnDestroy(): void { - this.electronService.removeAllListeners(EPG_GET_PROGRAM_DONE); + this.dataService.removeAllListeners(EPG_GET_PROGRAM_DONE); } } diff --git a/src/app/player/components/html-video-player/html-video-player.component.ts b/src/app/player/components/html-video-player/html-video-player.component.ts index 9624d94d3..d32dda43f 100644 --- a/src/app/player/components/html-video-player/html-video-player.component.ts +++ b/src/app/player/components/html-video-player/html-video-player.component.ts @@ -75,32 +75,61 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { if (channel.url) { const url = channel.url + (channel.epgParams ?? ''); const extension = getExtensionFromUrl(channel.url); - this.dataService.sendIpcEvent(CHANNEL_SET_USER_AGENT, { - userAgent: channel.http?.['user-agent'] ?? '', - referer: channel.http?.referrer ?? '', - }); - if ( - extension !== 'mp4' && - extension !== 'mpv' && - Hls && - Hls.isSupported() - ) { - console.log('... switching channel to ', channel.name, url); - this.hls = new Hls(); - this.hls.attachMedia(this.videoPlayer.nativeElement); - this.hls.loadSource(url); - - this.handlePlayOperation(); - } else { - console.error('something wrong with hls.js init...'); - this.addSourceToVideo( - this.videoPlayer.nativeElement, - url, - 'video/mp4' - ); - this.videoPlayer.nativeElement.play(); - } + // Send IPC event and handle the response + this.dataService + .sendIpcEvent(CHANNEL_SET_USER_AGENT, { + userAgent: channel.http?.['user-agent'] ?? '', + referer: channel.http?.referrer ?? '', + }) + .then(() => { + if ( + extension !== 'mp4' && + extension !== 'mpv' && + Hls && + Hls.isSupported() + ) { + console.log( + '... switching channel to ', + channel.name, + url + ); + this.hls = new Hls(); + this.hls.attachMedia(this.videoPlayer.nativeElement); + this.hls.loadSource(url); + this.handlePlayOperation(); + } else { + console.log('Using native video player...'); + this.addSourceToVideo( + this.videoPlayer.nativeElement, + url, + 'video/mp4' + ); + this.videoPlayer.nativeElement.play(); + } + }) + .catch((error) => { + console.error('Error setting user agent:', error); + // Continue playback even if setting user agent fails + if ( + extension !== 'mp4' && + extension !== 'mpv' && + Hls && + Hls.isSupported() + ) { + this.hls = new Hls(); + this.hls.attachMedia(this.videoPlayer.nativeElement); + this.hls.loadSource(url); + this.handlePlayOperation(); + } else { + this.addSourceToVideo( + this.videoPlayer.nativeElement, + url, + 'video/mp4' + ); + this.videoPlayer.nativeElement.play(); + } + }); } } diff --git a/src/app/player/components/info-overlay/info-overlay.component.html b/src/app/player/components/info-overlay/info-overlay.component.html index 224bdc013..9794878ea 100644 --- a/src/app/player/components/info-overlay/info-overlay.component.html +++ b/src/app/player/components/info-overlay/info-overlay.component.html @@ -6,17 +6,17 @@ >
- {{ epgProgram?.title[0]?.value }} | {{ channel.name }} + {{ epgProgram?.title }} | {{ channel.name }}
- {{ epgProgram?.desc[0]?.value }} + {{ epgProgram?.desc }}
- @if (item.icon[0]) { + @if (item.icon) { } - @if (!item.icon[0]) { + @if (!item.icon) {
- {{ item.name[0].value }} + {{ item.name }}
} @@ -99,7 +99,7 @@ @@ -118,7 +118,7 @@ [attr.x]="program.startPosition" >
diff --git a/src/app/player/components/multi-epg/multi-epg-container.component.scss b/src/app/player/components/multi-epg/multi-epg-container.component.scss index 17e51e4a6..e787f143a 100644 --- a/src/app/player/components/multi-epg/multi-epg-container.component.scss +++ b/src/app/player/components/multi-epg/multi-epg-container.component.scss @@ -28,7 +28,7 @@ rect { #epg-container { width: calc(100vw - 100px); - height: calc(100vh - 73px); + height: calc(100vh - 42px); overflow-x: scroll; overflow-y: hidden; } @@ -53,7 +53,6 @@ rect { #epg-navigation { display: flex; background-color: white; - margin-top: 30px; border-bottom: 2px solid #fff; align-items: center; } diff --git a/src/app/player/components/video-player/toolbar/toolbar.component.html b/src/app/player/components/video-player/toolbar/toolbar.component.html index 937ce8b4b..a2e1933e1 100644 --- a/src/app/player/components/video-player/toolbar/toolbar.component.html +++ b/src/app/player/components/video-player/toolbar/toolbar.component.html @@ -7,23 +7,32 @@ > menu - + + {{ + (favorites$ | async).includes(activeChannel?.id) + ? 'star' + : 'star_outline' + }} + + } {{ activeChannel?.name }}
- + + @let isEpgAvailable = isEpgAvailable$ | async; + @if (isEpgAvailable) { - + } diff --git a/src/app/player/components/video-player/toolbar/toolbar.component.ts b/src/app/player/components/video-player/toolbar/toolbar.component.ts index 7eea32a36..592300a8f 100644 --- a/src/app/player/components/video-player/toolbar/toolbar.component.ts +++ b/src/app/player/components/video-player/toolbar/toolbar.component.ts @@ -1,6 +1,7 @@ import { AsyncPipe, CommonModule } from '@angular/common'; import { Component, EventEmitter, Input, Output } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatToolbarModule } from '@angular/material/toolbar'; @@ -8,6 +9,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { Store } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { Channel } from '../../../../../../shared/channel.interface'; +import { SettingsComponent } from '../../../../settings/settings.component'; import { updateFavorites } from '../../../../state/actions'; import { selectActivePlaylistId, @@ -25,6 +27,7 @@ import { MatToolbarModule, MatTooltipModule, TranslateModule, + MatDialogModule, ], selector: 'app-toolbar', templateUrl: './toolbar.component.html', @@ -43,7 +46,8 @@ export class ToolbarComponent { constructor( private snackBar: MatSnackBar, private store: Store, - private translateService: TranslateService + private translateService: TranslateService, + private dialog: MatDialog ) {} /** @@ -58,4 +62,12 @@ export class ToolbarComponent { ); this.store.dispatch(updateFavorites({ channel })); } + + openSettings(): void { + this.dialog.open(SettingsComponent, { + width: '1000px', + height: '90%', + data: { isDialog: true }, + }); + } } diff --git a/src/app/player/components/video-player/video-player.component.html b/src/app/player/components/video-player/video-player.component.html index 327a10371..8bbc053e6 100644 --- a/src/app/player/components/video-player/video-player.component.html +++ b/src/app/player/components/video-player/video-player.component.html @@ -90,6 +90,6 @@ - + diff --git a/src/app/player/components/video-player/video-player.component.ts b/src/app/player/components/video-player/video-player.component.ts index 482869683..3f0236e4f 100644 --- a/src/app/player/components/video-player/video-player.component.ts +++ b/src/app/player/components/video-player/video-player.component.ts @@ -8,6 +8,7 @@ import { NgZone, OnDestroy, OnInit, + effect, } from '@angular/core'; import { MatSidenavModule } from '@angular/material/sidenav'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -25,6 +26,7 @@ import { import { Playlist } from '../../../../../shared/playlist.interface'; import { DataService } from '../../../services/data.service'; import { PlaylistsService } from '../../../services/playlists.service'; +import { SettingsStore } from '../../../services/settings-store.service'; import { Settings, VideoPlayer } from '../../../settings/settings.interface'; import { STORE_KEY } from '../../../shared/enums/store-keys.enum'; import * as PlaylistActions from '../../../state/actions'; @@ -118,7 +120,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { listeners = []; - isElectron = this.dataService.isElectron; + isTauri = this.dataService.getAppEnvironment() === 'tauri'; sidebarView: SidebarView = 'CHANNELS'; @@ -136,13 +138,22 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { private router: Router, private snackBar: MatSnackBar, private storage: StorageMap, - private store: Store + private store: Store, + private settingsStore: SettingsStore ) { // Initialize volume from localStorage in constructor const savedVolume = localStorage.getItem('volume'); if (savedVolume !== null) { this.volume = Number(savedVolume); } + + // React to settings changes + effect(() => { + this.playerSettings = { + player: this.settingsStore.player(), + showCaptions: this.settingsStore.showCaptions(), + }; + }); } /** @@ -210,7 +221,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { */ setRendererListeners(): void { this.commandsList.forEach((command) => { - if (this.isElectron) { + if (this.isTauri) { this.dataService.listenOn(command.id, (event, response) => this.ngZone.run(() => command.execute(response)) ); @@ -243,7 +254,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { } ngOnDestroy() { - if (this.isElectron) { + if (this.isTauri) { this.dataService.removeAllListeners(PLAYLIST_PARSE_RESPONSE); } else { this.listeners.forEach((listener) => diff --git a/src/app/player/models/epg-channel.model.ts b/src/app/player/models/epg-channel.model.ts index 1f3fbcb28..f0c47e4d1 100644 --- a/src/app/player/models/epg-channel.model.ts +++ b/src/app/player/models/epg-channel.model.ts @@ -1,6 +1,6 @@ export interface EpgChannel { id: string; - name: { lang: string; value: string }[]; + name: string; icon: string[]; url: string[]; } diff --git a/src/app/portals/epg-view/epg-view.component.ts b/src/app/portals/epg-view/epg-view.component.ts index 1ea036d4b..0f92ae0ca 100644 --- a/src/app/portals/epg-view/epg-view.component.ts +++ b/src/app/portals/epg-view/epg-view.component.ts @@ -15,7 +15,6 @@ import { EpgItem } from '../../xtream/epg-item.interface'; templateUrl: './epg-view.component.html', imports: [ DatePipe, - EpgItemDescriptionComponent, MatButtonModule, MatDividerModule, MatIconModule, @@ -24,8 +23,8 @@ import { EpgItem } from '../../xtream/epg-item.interface'; ], styles: ` mat-list { - overflow-y: scroll; - height: calc(100vh - 459px); + /* overflow-y: scroll; */ + height: 100vh; } `, }) @@ -37,8 +36,8 @@ export class EpgViewComponent { showDetails(item: EpgItem) { this.dialog.open(EpgItemDescriptionComponent, { data: { - title: [{ value: item.title, lang: item.lang }], - desc: [{ value: item.description }], + title: item.title ?? 'No title', + desc: item.description ?? 'No description', }, }); } diff --git a/src/app/portals/live-stream-layout/live-stream-layout.component.scss b/src/app/portals/live-stream-layout/live-stream-layout.component.scss index 5598e6bbd..8fe24537d 100644 --- a/src/app/portals/live-stream-layout/live-stream-layout.component.scss +++ b/src/app/portals/live-stream-layout/live-stream-layout.component.scss @@ -7,12 +7,11 @@ .itv-container { display: grid; grid-template-columns: 330px 1fr 1fr; - grid-template-rows: 1fr 1fr 1fr; + grid-template-rows: 50% 50%; gap: 0 0; grid-auto-flow: row; grid-template-areas: 'sidebar video-player video-player' - 'sidebar epg epg' 'sidebar epg epg'; height: 100%; } @@ -49,9 +48,10 @@ } .epg-content { - height: 100%; + height: calc(50vh - 20px); overflow-y: auto; border-right: #111 1px solid; + padding-bottom: 20px; } .sidebar-content { diff --git a/src/app/portals/web-player-view/web-player-view.component.scss b/src/app/portals/web-player-view/web-player-view.component.scss index 3e829143d..4f278607c 100644 --- a/src/app/portals/web-player-view/web-player-view.component.scss +++ b/src/app/portals/web-player-view/web-player-view.component.scss @@ -1,3 +1,7 @@ app-vjs-player > .video-js { - height: 100%; + height: 50vh !important; +} + +app-html-video-player > #video-player { + height: 50vh !important; } diff --git a/src/app/services/epg.service.ts b/src/app/services/epg.service.ts index a4b0c6d45..1164c2d7e 100644 --- a/src/app/services/epg.service.ts +++ b/src/app/services/epg.service.ts @@ -1,66 +1,125 @@ import { Injectable } from '@angular/core'; -import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { EPG_FETCH } from '../../../shared/ipc-commands'; -import { setEpgAvailableFlag } from '../state/actions'; -import { DataService } from './data.service'; +import { invoke } from '@tauri-apps/api/core'; +import { BehaviorSubject, from } from 'rxjs'; +import { catchError, map, tap } from 'rxjs/operators'; +import { EpgProgram } from '../player/models/epg-program.model'; +import * as PlaylistActions from '../state/actions'; @Injectable({ providedIn: 'root', }) export class EpgService { - /** Default options for epg snackbar notifications */ - epgSnackBarOptions: MatSnackBarConfig = { - verticalPosition: 'bottom', - horizontalPosition: 'start', - }; + private epgAvailable = new BehaviorSubject(false); + private currentEpgPrograms = new BehaviorSubject([]); + + epgAvailable$ = this.epgAvailable.asObservable(); + currentEpgPrograms$ = this.currentEpgPrograms.asObservable(); constructor( - private store: Store, - private electronService: DataService, private snackBar: MatSnackBar, - private translate: TranslateService + private translate: TranslateService, + private store: Store ) {} /** - * Fetches and updates EPG from the given sources - * @param urls epg source urls + * Fetches EPG from the given URLs */ - fetchEpg(urls: string | string[]) { - const urlsArray = Array.isArray(urls) ? urls : [urls]; - urlsArray.forEach((url) => - this.electronService.sendIpcEvent(EPG_FETCH, { - url, - }) - ); + fetchEpg(urls: string[]): void { this.showFetchSnackbar(); + + // Filter out empty URLs and send all URLs at once + const validUrls = urls.filter((url) => url?.trim()); + if (validUrls.length === 0) return; + + from(invoke('fetch_epg', { url: validUrls })) + .pipe( + tap(() => { + this.epgAvailable.next(true); + this.showSuccessSnackbar(); + }), + catchError((err) => { + console.error('EPG fetch error:', err); + this.epgAvailable.next(false); + this.showErrorSnackbar(); + throw err; + }) + ) + .subscribe(); } - showFetchSnackbar() { + /** + * Gets EPG programs for a specific channel + */ + getChannelPrograms(channelId: string): void { + console.log('Fetching EPG for channel ID:', channelId); + from(invoke('get_channel_programs', { channelId })) + .pipe( + tap((programs) => { + console.log('Received programs:', programs); + }), + map((programs) => + programs.map((program) => ({ + ...program, + start: new Date(program.start).toISOString(), + stop: new Date(program.stop).toISOString(), + })) + ), + catchError((err) => { + console.error('EPG get programs error:', err); + this.showErrorSnackbar(); + this.currentEpgPrograms.next([]); + throw err; + }) + ) + .subscribe((programs) => { + if (programs.length === 0) { + this.store.dispatch( + PlaylistActions.setEpgAvailableFlag({ value: false }) + ); + } else { + this.store.dispatch( + PlaylistActions.setEpgAvailableFlag({ value: true }) + ); + } + this.currentEpgPrograms.next(programs); + console.log('Updated programs:', programs); // Debug log + }); + } + + /** + * Shows fetch in progress snackbar + */ + showFetchSnackbar(): void { + this.snackBar.open(this.translate.instant('EPG.FETCH_EPG'), null, { + duration: 2000, + horizontalPosition: 'start', + }); + } + + /** + * Shows success snackbar + */ + private showSuccessSnackbar(): void { + this.snackBar.open(this.translate.instant('EPG.FETCH_SUCCESS'), null, { + duration: 2000, + horizontalPosition: 'start', + }); + } + + /** + * Shows error snackbar + */ + private showErrorSnackbar(): void { this.snackBar.open( - this.translate.instant('EPG.FETCH_EPG'), + this.translate.instant('EPG.ERROR'), this.translate.instant('CLOSE'), - this.epgSnackBarOptions - ); - } - - onEpgFetchDone() { - this.store.dispatch(setEpgAvailableFlag({ value: true })); - this.snackBar.open( - this.translate.instant('EPG.DOWNLOAD_SUCCESS'), - null, { - ...this.epgSnackBarOptions, duration: 2000, + horizontalPosition: 'start', } ); } - - onEpgError() { - this.snackBar.open(this.translate.instant('EPG.ERROR'), null, { - ...this.epgSnackBarOptions, - duration: 2000, - }); - } } diff --git a/src/app/services/playlists.service.ts b/src/app/services/playlists.service.ts index 7c2d687c4..227c641e6 100644 --- a/src/app/services/playlists.service.ts +++ b/src/app/services/playlists.service.ts @@ -143,20 +143,34 @@ export class PlaylistsService { getPortalFavorites(portalId: string) { return this.dbService - .getByID<{ favorites: Partial[] }>( - DbStores.Playlists, - portalId - ) - .pipe(map((item) => item.favorites.filter(itm => itm && itm.stream_type && itm.stream_type !== 'live') ?? [])); + .getByID<{ + favorites: Partial[]; + }>(DbStores.Playlists, portalId) + .pipe( + map((item) => { + if (!item || !item.favorites) return []; + return item.favorites.filter( + (itm) => + itm && itm.stream_type && itm.stream_type !== 'live' + ); + }) + ); } getPortalLiveStreamFavorites(portalId: string) { return this.dbService - .getByID<{ favorites: Partial[] }>( - DbStores.Playlists, - portalId - ) - .pipe(map((item) => item.favorites.filter(itm => itm && itm.stream_type && itm.stream_type === 'live') ?? [])); + .getByID<{ + favorites: Partial[]; + }>(DbStores.Playlists, portalId) + .pipe( + map((item) => { + if (!item || !item.favorites) return []; + return item.favorites.filter( + (itm) => + itm && itm.stream_type && itm.stream_type === 'live' + ); + }) + ); } addPortalFavorite(portalId: string, item: any) { @@ -217,6 +231,7 @@ export class PlaylistsService { ) { try { const parsedPlaylist = parse(playlist); + console.log(parsedPlaylist); return createPlaylistObject( title, parsedPlaylist, diff --git a/src/app/services/pwa.service.ts b/src/app/services/pwa.service.ts index 7e5f6a1b3..a9aa4dd6b 100644 --- a/src/app/services/pwa.service.ts +++ b/src/app/services/pwa.service.ts @@ -1,5 +1,5 @@ import { HttpClient } from '@angular/common/http'; -import { ApplicationRef, inject, Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { SwUpdate } from '@angular/service-worker'; import { Store } from '@ngrx/store'; @@ -19,23 +19,21 @@ import { Playlist } from '../../../shared/playlist.interface'; import { AppConfig } from '../../environments/environment'; import * as PlaylistActions from '../state/actions'; import { DataService } from './data.service'; -import { PlaylistsService } from './playlists.service'; @Injectable({ providedIn: 'root', }) export class PwaService extends DataService { - appRef = inject(ApplicationRef); - playlistService = inject(PlaylistsService); - snackBar = inject(MatSnackBar); - store = inject(Store); - swUpdate = inject(SwUpdate); - translateService = inject(TranslateService); + private readonly http = inject(HttpClient); + private readonly snackBar = inject(MatSnackBar); + private readonly store = inject(Store); + private readonly swUpdate = inject(SwUpdate); + private readonly translateService = inject(TranslateService); /** Proxy URL to avoid CORS issues */ corsProxyUrl = AppConfig.BACKEND_URL; - constructor(private http: HttpClient) { + constructor() { super(); console.log('PWA service initialized...'); } diff --git a/src/app/services/settings-store.service.ts b/src/app/services/settings-store.service.ts new file mode 100644 index 000000000..db817cf1d --- /dev/null +++ b/src/app/services/settings-store.service.ts @@ -0,0 +1,51 @@ +import { Injectable, computed, signal } from '@angular/core'; +import { StorageMap } from '@ngx-pwa/local-storage'; +import { Language } from '../settings/language.enum'; +import { Settings, VideoPlayer } from '../settings/settings.interface'; +import { Theme } from '../settings/theme.enum'; +import { STORE_KEY } from '../shared/enums/store-keys.enum'; + +const DEFAULT_SETTINGS: Settings = { + player: VideoPlayer.VideoJs, + language: Language.ENGLISH, + showCaptions: false, + theme: Theme.LightTheme, + mpvPlayerPath: '', + vlcPlayerPath: '', + remoteControl: false, + remoteControlPort: 3000, + epgUrl: [], +}; + +@Injectable({ + providedIn: 'root', +}) +export class SettingsStore { + private settings = signal(DEFAULT_SETTINGS); + + // Computed values for commonly used settings + readonly player = computed(() => this.settings().player); + readonly showCaptions = computed(() => this.settings().showCaptions); + readonly theme = computed(() => this.settings().theme); + + constructor(private storage: StorageMap) { + this.loadSettings(); + } + + async loadSettings() { + const stored = await this.storage.get(STORE_KEY.Settings).toPromise(); + if (stored) { + this.settings.set({ ...DEFAULT_SETTINGS, ...(stored as Settings) }); + } + } + + async updateSettings(settings: Partial) { + const newSettings = { ...this.settings(), ...settings }; + this.settings.set(newSettings); + await this.storage.set(STORE_KEY.Settings, newSettings).toPromise(); + } + + getSettings() { + return this.settings; + } +} diff --git a/src/app/services/tauri.service.ts b/src/app/services/tauri.service.ts index 8727e1734..e83f28725 100644 --- a/src/app/services/tauri.service.ts +++ b/src/app/services/tauri.service.ts @@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core'; import { fetch } from '@tauri-apps/plugin-http'; import { parse } from 'iptv-playlist-parser'; import { + EPG_GET_PROGRAM_DONE, ERROR, PLAYLIST_PARSE_BY_URL, PLAYLIST_PARSE_RESPONSE, @@ -66,6 +67,11 @@ export class TauriService extends DataService { }); throw error; }); + } else if (type === 'EPG_FETCH_DONE') { + window.postMessage({ + type: EPG_GET_PROGRAM_DONE, + payload, + }); } else { console.log('Unknown type', type); } @@ -184,7 +190,10 @@ export class TauriService extends DataService { } removeAllListeners(type: string): void { - throw new Error('Method not implemented.'); + console.error( + 'Method not implemented. Following type was provided:', + type + ); } listenOn(command: string, callback: (...args: any[]) => void): void { diff --git a/src/app/settings/settings.component.html b/src/app/settings/settings.component.html index 79c3a0ab5..e6fcdae54 100644 --- a/src/app/settings/settings.component.html +++ b/src/app/settings/settings.component.html @@ -1,15 +1,19 @@ - +@if (!isDialog) { + +} @else { +

{{ 'SETTINGS.TITLE' | translate }}

+}
- +
{{ 'SETTINGS.EPG_URL_LABEL' | translate }} @@ -205,7 +209,7 @@ >
- @if (isPwa || isElectron) { + @if (isPwa || isTauri) {
@@ -311,12 +315,14 @@
-
- {{ 'SETTINGS.EPG_NOTE' | translate }} -  {{ - 'SETTINGS.EPG_NOTE_URL_TEXT' | translate - }} -
+ @if (isPwa) { +
+ {{ 'SETTINGS.EPG_NOTE' | translate }} +  {{ + 'SETTINGS.EPG_NOTE_URL_TEXT' | translate + }} +
+ }
diff --git a/src/app/settings/settings.component.ts b/src/app/settings/settings.component.ts index 8e31459dd..205b45c17 100644 --- a/src/app/settings/settings.component.ts +++ b/src/app/settings/settings.component.ts @@ -1,5 +1,6 @@ +/* eslint-disable @typescript-eslint/no-base-to-string */ import { CommonModule } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; +import { Component, Inject, Input, OnInit, Optional } from '@angular/core'; import { FormArray, FormBuilder, @@ -10,6 +11,11 @@ import { } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; +import { + MAT_DIALOG_DATA, + MatDialog, + MatDialogModule, +} from '@angular/material/dialog'; import { MatDividerModule } from '@angular/material/divider'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; @@ -22,7 +28,6 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { take } from 'rxjs'; import * as semver from 'semver'; import { - EPG_FORCE_FETCH, SET_MPV_PLAYER_PATH, SET_VLC_PLAYER_PATH, SETTINGS_UPDATE, @@ -32,13 +37,13 @@ import { DataService } from '../services/data.service'; import { DialogService } from '../services/dialog.service'; import { EpgService } from '../services/epg.service'; import { PlaylistsService } from '../services/playlists.service'; +import { SettingsStore } from '../services/settings-store.service'; import { HeaderComponent } from '../shared/components/header/header.component'; -import { STORE_KEY } from '../shared/enums/store-keys.enum'; import * as PlaylistActions from '../state/actions'; import { selectIsEpgAvailable } from '../state/selectors'; import { SettingsService } from './../services/settings.service'; import { Language } from './language.enum'; -import { Settings, VideoPlayer } from './settings.interface'; +import { VideoPlayer } from './settings.interface'; import { Theme } from './theme.enum'; @Component({ @@ -58,14 +63,16 @@ import { Theme } from './theme.enum'; MatTooltipModule, ReactiveFormsModule, TranslateModule, + MatDialogModule, ], }) export class SettingsComponent implements OnInit { + @Input() isDialog = false; /** List with available languages as enum */ languageEnum = Language; /** Flag that indicates whether the app runs in electron environment */ - isElectron = this.electronService.isElectron; + isTauri = this.electronService.getAppEnvironment() === 'tauri'; isPwa = this.electronService.getAppEnvironment() === 'pwa'; @@ -91,7 +98,7 @@ export class SettingsComponent implements OnInit { label: 'VideoJs Player', }, ...this.electronPlayers, - /* ...(this.isElectron ? this.electronPlayers : []), */ + /* ...(this.isTauri ? this.electronPlayers : []), */ ]; /** Current version of the app */ @@ -109,7 +116,7 @@ export class SettingsComponent implements OnInit { /** Settings form object */ settingsForm = this.formBuilder.group({ player: [VideoPlayer.VideoJs], - ...(this.isElectron ? { epgUrl: new FormArray([]) } : {}), + ...(this.isTauri ? { epgUrl: new FormArray([]) } : {}), language: Language.ENGLISH, showCaptions: false, theme: Theme.LightTheme, @@ -136,8 +143,13 @@ export class SettingsComponent implements OnInit { private settingsService: SettingsService, private snackBar: MatSnackBar, private store: Store, - private translate: TranslateService - ) {} + private translate: TranslateService, + private matDialog: MatDialog, + private settingsStore: SettingsStore, + @Optional() @Inject(MAT_DIALOG_DATA) data?: { isDialog: boolean } + ) { + this.isDialog = data?.isDialog ?? false; + } /** * Reads the config object from the browsers @@ -152,34 +164,12 @@ export class SettingsComponent implements OnInit { * Sets saved settings from the indexed db store */ setSettings(): void { - this.settingsService - .getValueFromLocalStorage(STORE_KEY.Settings) - .subscribe((settings: Settings) => { - if (settings) { - try { - this.settingsForm.setValue({ - player: settings.player - ? settings.player - : VideoPlayer.VideoJs, - ...(this.isElectron ? { epgUrl: [] } : {}), - language: settings.language ?? Language.ENGLISH, - showCaptions: settings.showCaptions ?? false, - theme: settings.theme ?? Theme.LightTheme, - mpvPlayerPath: settings.mpvPlayerPath ?? '', - vlcPlayerPath: settings.vlcPlayerPath ?? '', - remoteControl: settings.remoteControl ?? false, - remoteControlPort: - settings.remoteControlPort ?? 3000, - }); - } catch (error) { - throw new Error(error); - } + const currentSettings = this.settingsStore.getSettings()(); + this.settingsForm.patchValue(currentSettings); - if (this.isElectron) { - this.setEpgUrls(settings.epgUrl); - } - } - }); + if (this.isTauri && currentSettings.epgUrl) { + this.setEpgUrls(currentSettings.epgUrl); + } } /** @@ -248,31 +238,26 @@ export class SettingsComponent implements OnInit { * the indexed db store */ onSubmit(): void { - this.settingsService - .setValueToLocalStorage( - STORE_KEY.Settings, - this.settingsForm.value, - true - ) - .pipe(take(1)) - .subscribe(() => { - this.applyChangedSettings(); - }); + this.settingsStore.updateSettings(this.settingsForm.value).then(() => { + this.applyChangedSettings(); + this.electronService.sendIpcEvent( + SETTINGS_UPDATE, + this.settingsForm.value + ); - this.electronService.sendIpcEvent( - SETTINGS_UPDATE, - this.settingsForm.value - ); + this.electronService.sendIpcEvent( + SET_MPV_PLAYER_PATH, + this.settingsForm.value.mpvPlayerPath + ); - this.electronService.sendIpcEvent( - SET_MPV_PLAYER_PATH, - this.settingsForm.value.mpvPlayerPath - ); - - this.electronService.sendIpcEvent( - SET_VLC_PLAYER_PATH, - this.settingsForm.value.mpvPlayerPath - ); + this.electronService.sendIpcEvent( + SET_VLC_PLAYER_PATH, + this.settingsForm.value.mpvPlayerPath + ); + }); + if (this.isDialog) { + this.matDialog.closeAll(); + } } /** @@ -280,15 +265,17 @@ export class SettingsComponent implements OnInit { */ applyChangedSettings(): void { this.settingsForm.markAsPristine(); - // check whether the epg url was changed or not - if (this.isElectron) { + if (this.isTauri) { let epgUrls = this.settingsForm.value.epgUrl; if (epgUrls) { if (!Array.isArray(epgUrls)) { epgUrls = [epgUrls]; } epgUrls = epgUrls.filter((url) => url !== ''); - this.epgService.fetchEpg(epgUrls); + if (epgUrls.length > 0) { + // Fetch all EPG URLs at once + this.epgService.fetchEpg(epgUrls); + } } } this.translate.use(this.settingsForm.value.language); @@ -307,7 +294,11 @@ export class SettingsComponent implements OnInit { * Navigates back to the applications homepage */ backToHome(): void { - this.router.navigateByUrl('/'); + if (this.isDialog) { + this.matDialog.closeAll(); + } else { + this.router.navigateByUrl('/'); + } } /** @@ -315,8 +306,7 @@ export class SettingsComponent implements OnInit { * @param url epg source url */ refreshEpg(url: string): void { - this.electronService.sendIpcEvent(EPG_FORCE_FETCH, url); - this.epgService.showFetchSnackbar(); + this.epgService.fetchEpg([url]); } /** @@ -394,6 +384,7 @@ export class SettingsComponent implements OnInit { duration: 2000, } ); + console.error(error); } }; reader.readAsText(file); diff --git a/src/app/stalker/stalker-main-container.component.ts b/src/app/stalker/stalker-main-container.component.ts index ca1f60634..8d9095f1a 100644 --- a/src/app/stalker/stalker-main-container.component.ts +++ b/src/app/stalker/stalker-main-container.component.ts @@ -370,33 +370,36 @@ export class StalkerMainContainerComponent implements OnInit { } openPlayer(streamUrl: string) { - const player = this.settings()?.player ?? VideoPlayer.VideoJs; - if (player === VideoPlayer.MPV) { - if (!this.hideExternalInfoDialog()) - this.dialog.open(ExternalPlayerInfoDialogComponent); - this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, { - url: streamUrl, - mpvPlayerPath: this.settings()?.mpvPlayerPath, - }); - } else if (player === VideoPlayer.VLC) { - if (!this.hideExternalInfoDialog()) - this.dialog.open(ExternalPlayerInfoDialogComponent); - this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, { - url: streamUrl, - vlcPlayerPath: this.settings()?.vlcPlayerPath, - }); - } else { - this.dialog.open( - PlayerDialogComponent, - { - data: { - streamUrl, - title: this.itvTitle, - }, - width: '80%', - } - ); - } + // Get fresh settings directly from storage instead of using signal + this.storage.get(STORE_KEY.Settings).subscribe((settings: Settings) => { + const player = settings?.player ?? VideoPlayer.VideoJs; + if (player === VideoPlayer.MPV) { + if (!this.hideExternalInfoDialog()) + this.dialog.open(ExternalPlayerInfoDialogComponent); + this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, { + url: streamUrl, + mpvPlayerPath: settings?.mpvPlayerPath, + }); + } else if (player === VideoPlayer.VLC) { + if (!this.hideExternalInfoDialog()) + this.dialog.open(ExternalPlayerInfoDialogComponent); + this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, { + url: streamUrl, + vlcPlayerPath: settings?.vlcPlayerPath, + }); + } else { + this.dialog.open( + PlayerDialogComponent, + { + data: { + streamUrl, + title: this.itvTitle, + }, + width: '80%', + } + ); + } + }); } categoryClicked(item: { category_name: string; category_id: string }) { diff --git a/src/app/xtream/navigation-bar/navigation-bar.component.html b/src/app/xtream/navigation-bar/navigation-bar.component.html index 03fb28251..cbe9d6eca 100644 --- a/src/app/xtream/navigation-bar/navigation-bar.component.html +++ b/src/app/xtream/navigation-bar/navigation-bar.component.html @@ -36,6 +36,11 @@ 'CHANNELS.FAVORITES' | translate }} + +
@@ -59,11 +64,7 @@ [matTooltip]="'TOP_MENU.TOGGLE_FAVORITE_FLAG' | translate" > - {{ - isFavoriteStream - ? 'star' - : 'star_outline' - }} + {{ isFavoriteStream ? 'star' : 'star_outline' }}
@@ -91,9 +92,7 @@
-
+