From 3c6f7d941f6fed49e56b2eb4b528ff48dadbb164 Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 09:12:14 -0300 Subject: [PATCH 01/19] docs(audit): add security and performance audit 2026-10-24 --- docs/audit/security-performance-2026-10-24.md | 456 ++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 docs/audit/security-performance-2026-10-24.md diff --git a/docs/audit/security-performance-2026-10-24.md b/docs/audit/security-performance-2026-10-24.md new file mode 100644 index 00000000..5a1a0de7 --- /dev/null +++ b/docs/audit/security-performance-2026-10-24.md @@ -0,0 +1,456 @@ +# Security & Performance Audit: PlayTorrioZero (ZPlay) + +**Date:** 2026-10-24 +**Auditor:** OpenCode agent (DeepSeek V4.1 Flash), using the `audit-full` skill +**Mode:** Security + Performance + Dependencies +**Scope:** Entire repository at commit `a239d9e` (`v1.6.16+3053`), 2026-10-20 +**Status:** Read-only review. No code was changed. + +## Summary + +| Metric | Value | +|--------|-------| +| Dart files | 421 | +| Dart lines | 172,626 | +| Direct dependencies | 62 declared, 140 hosted packages resolved | +| OSV advisories | 0 (Pub ecosystem, querybatch) | +| Security findings | 5 High, 6 Medium, 5 Low | +| Performance findings | 4 High, 5 Medium, 3 Low | + +### Findings Overview + +| Severity | Security | Performance | +|----------|----------|-------------| +| CRITICAL | 0 | 0 | +| HIGH | 5 | 4 | +| MEDIUM | 6 | 5 | +| LOW | 5 | 3 | +| **Total** | **16** | **12** | + +**Overall Health Score: 5/10 (C)** +Breakdown: security 5/10, performance 5/10, dependencies 9/10. Applying the `audit-full` severity formula literally gives 2.7/10 for security and 4.2/10 for performance; the scores above are adjusted for reachability, because the highest-impact issues sit in optional, user-installed subsystems rather than in a remotely reachable service. + +**Bottom line:** the app's foundations are stronger than its integrations. Secret storage, backups, diagnostics, and dependency hygiene are good. The two dominant risks are trust boundaries: the CloudStream plugin/runtime pipeline executes unsigned third-party code, and the updater installs and runs downloads without verifying integrity. Performance is dominated by four screens (EPUB reader, music, Home, manga reader) doing per-event disk or network work that should be coalesced. + +## What Is Solid + +These were checked and should not be regressed: + +- **Credentials at rest:** `SecureStore` uses the platform keystore with a migration path. No first-party upstream API keys are hardcoded; `legacy_upstream_credentials.dart` is deliberately blank. The one exception is a third-party Qobuz worker token (S-13). +- **Profile export:** AES-256-GCM with PBKDF2 and careful redaction of tokens before export. +- **Backups:** secret fields are redacted and the archive is encrypted, not just zipped. +- **Android OTA FileProvider:** scoped to the `ota_update/` subdirectory (`android/app/src/main/res/xml/filepaths.xml:3`). +- **Diagnostics:** secrets are scrubbed and the bundle is not uploaded automatically. +- **Trailer WebView:** local wrapper plus correct JS/HTML escaping of the video key; no JavaScript channels. +- **CI:** `flutter analyze`, format check, tests, and a Windows smoke build run in `.github/workflows/quality.yml`; tagged Android releases hard-fail without the release keystore. +- **Dependencies:** 140 hosted packages queried against OSV on 2026-10-24; zero known advisories. + +## Security Findings + +### HIGH + +#### S-01: CloudStream executes unsigned third-party code with full app privileges +- **Category:** Security / supply chain +- **OWASP:** A08:2021 Software and Data Integrity Failures +- **Files:** `lib/services/cloudstream/marketplace/cloudstream_marketplace_service.dart:98-119`, `lib/services/cloudstream/cloudstream_manager.dart:620-695`, `lib/services/cloudstream/runtime/cloudstream_downloader.dart:22-47`, `android/app/src/main/kotlin/io/github/tzero86/zplay/CloudStreamNativeBridge.kt:87-170`, `lib/services/cloudstream/runtime/cloudstream_sidecar.dart:39-45` +- **Description:** The marketplace scrapes repositories from the unofficial `cloudstream-apk.com` site. Extensions are downloaded from arbitrary repo-supplied URLs and executed in-process: on Android via `DexClassLoader`, on desktop as JARs run by a JVM launched with `-noverify`. Runtime downloads (runtime host APK, bridge JAR, JRE, dex2jar) come from `releases/latest` URLs with no checksum or signature verification. +- **Impact:** A malicious repository, a compromised upstream release, or a MITM on a non-TLS repo yields arbitrary code execution with the app's permissions: stored tokens, debrid keys, files, network access, and session data. There is no sandbox, signature check, or explicit risk disclosure beyond the normal install confirmation. +- **Evidence:** + ```dart + // cloudstream_downloader.dart:23-27 - versionless "latest" downloads, no hash + 'https://github.com/.../releases/latest/download/anymex_runtime_host.apk'; + 'https://github.com/.../releases/latest/download/anymex_desktop_runtime.jar'; + ``` + ```kotlin + // CloudStreamNativeBridge.kt:127-130 + val loader = object : DexClassLoader( + cacheApk.absolutePath, + optimizedDir.absolutePath, + libsDir.absolutePath, + context.classLoader) + ``` + ```dart + // cloudstream_sidecar.dart:45 + '-noverify', + ``` +- **Remediation:** Pin runtime artifacts by version and SHA-256; verify before execution. Sign extension manifests and verify with an embedded public key. Add an explicit "this runs third-party code with full app access" consent screen. Long term, move plugin execution out-of-process behind a narrow IPC surface, and remove `-noverify`. If these are not feasible, stop auto-listing third-party repositories and remove the marketplace scraper. +- **Effort:** High + +#### S-02: Zip-slip in runtime archive extraction (Dart and Kotlin) +- **Category:** Security / path traversal +- **OWASP:** A01:2021 Broken Access Control +- **Files:** `lib/services/cloudstream/runtime/cloudstream_downloader.dart:249-282`, `android/app/src/main/kotlin/io/github/tzero86/zplay/CloudStreamNativeBridge.kt:172-203` +- **Description:** Archive entry names are joined to a target directory without canonicalization. In Dart, JRE/dex2jar `.zip` and `.tar.gz` entries are extracted with `File(p.join(targetDir, filename))`. In Kotlin, APK entries under `lib//` are written to `File(targetDir, libName)` and then marked executable. An entry named `../../payload` escapes the target directory. +- **Impact:** Arbitrary file write with app permissions; the Kotlin path additionally sets the executable bit, so a written `.so` can be loaded on the next runtime start. A malicious or compromised runtime archive turns into code execution. +- **Evidence:** + ```dart + // cloudstream_downloader.dart:257-262 + final filename = file.name; + File(p.join(targetDir, filename)) + ..createSync(recursive: true) + ..writeAsBytesSync(data); + ``` + ```kotlin + // CloudStreamNativeBridge.kt:192-203 + val libName = entry.name.substring(prefix.length) + val outFile = File(targetDir, libName) + ... + outFile.setExecutable(true, false) + ``` +- **Remediation:** Resolve each entry against the canonical target root and reject anything that is not a strict child: + ```dart + final root = Directory(targetDir).absolute.path; + final out = File(p.join(root, filename)).absolute.path; + if (!p.isWithin(root, out)) throw StateError('archive entry escapes root'); + ``` + Apply the same check in Kotlin using `canonicalPath`, and never chmod extracted files executable unless the archive itself was verified. +- **Effort:** Low + +#### S-03: TLS certificate validation disabled in five HTTP clients +- **Category:** Security / transport +- **OWASP:** A02:2021 Cryptographic Failures +- **Files:** `lib/services/books/book_download_service.dart:94`, `lib/services/music/music_download_service.dart:370,393`, `lib/services/subtitles/subtitlecat_service.dart:48`, `lib/services/subtitles/providers/subtitlecat_provider.dart:16`, `lib/services/anime/extractors/tryembed_extractor.dart:57` +- **Description:** Each client sets `badCertificateCallback = (cert, host, port) => true`, which accepts any certificate for any host. The book downloader is the most exposed: it applies this to `book.downloadUrl` and `book.link`, both supplied by remote metadata. +- **Impact:** A network attacker can impersonate the book API, music CDN, subtitle site, or extractor and substitute content or redirect downloads. For books, attacker-controlled bytes are written to disk and opened by a reader. This also makes the "HTTPS" protection on these paths cosmetic. +- **Evidence:** + ```dart + // book_download_service.dart:93-94 + final client = HttpClient(); + client.badCertificateCallback = (cert, host, port) => true; + ``` +- **Remediation:** Remove the callbacks. If a specific upstream has a broken chain, scope an exception to that exact host and pin its certificate/public key, never to all hosts. For extractors that only need HTML, use the normal `http` client. +- **Effort:** Low + +#### S-04: Update installer has no integrity verification and auto-runs downloads +- **Category:** Security / supply chain +- **OWASP:** A08:2021 Software and Data Integrity Failures +- **Files:** `lib/services/updater/app_updater_service.dart:149-264`, `lib/widgets/updater/update_dialog.dart:379-433,493-631` +- **Description:** The updater selects a `browser_download_url` from the GitHub release API and installs it. Android calls `OtaUpdate().execute(...)` without a checksum, so the plugin's `CHECKSUM_ERROR` branch can never trigger. Windows downloads an `.exe` to the Downloads folder and immediately runs it with `Process.start(..., detached)`. +- **Impact:** A compromised GitHub account/release or a TLS interception yields code execution. Android's APK signature check is a partial mitigation (an APK signed with a different key will not install), but the app itself does not verify the signer, and Windows has no signature check at all. +- **Evidence:** + ```dart + // update_dialog.dart:387-392 - no checksum argument + _otaSub = OtaUpdate() + .execute(widget.updateInfo.downloadUrl, + destinationFilename: 'ZPlay_${widget.updateInfo.latestVersion}.apk') + ``` + ```dart + // update_dialog.dart:624-630 + await Process.start(installer.path, const [], + mode: ProcessStartMode.detached); + await Future.delayed(const Duration(seconds: 2)); + exit(0); + ``` +- **Remediation:** Publish a `SHA256SUMS` asset with each release and verify the downloaded bytes before install. On Android, compare the APK signer certificate fingerprint with a pinned value. On Windows/macOS, code-sign the installer and verify the signature (or at minimum show the expected hash and require confirmation). Do not auto-run a freshly downloaded executable. +- **Effort:** Medium + +#### S-05: Remote extension name reaches filesystem paths and a shell +- **Category:** Security / path traversal, command injection surface +- **OWASP:** A01:2021 Broken Access Control, A03:2021 Injection +- **Files:** `lib/services/cloudstream/cloudstream_manager.dart:613-666,716-733,814-819` +- **Description:** `extName` is `source.internalName ?? source.name`, both supplied by repository metadata. It is interpolated into `temp_$extName`, `$extName.cs3`, and `$extName.jar` under the extensions directory, and the same names are used for deletion in `uninstallExtension`. The resulting paths also flow into `Process.run` with `runInShell: Platform.isWindows`. +- **Impact:** A malicious repo entry can write `.cs3`/`.jar` files outside the extensions directory, and can recursively delete arbitrary directories via the `temp_$extName` path. On Windows, shell metacharacters in the path may be interpreted by `cmd.exe`; even without that, the traversal is confirmed. +- **Evidence:** + ```dart + // cloudstream_manager.dart:613-616 + final extName = source.internalName ?? source.name; + final tempDir = Directory(p.join(extDir.path, 'temp_$extName')); + if (await tempDir.exists()) await tempDir.delete(recursive: true); + ``` + ```dart + // cloudstream_manager.dart:814-819 + final process = await Process.run( + dex2jarPath, ['--force', dexPath, '-o', outJarPath], + environment: env, runInShell: Platform.isWindows); + ``` +- **Remediation:** Sanitize `internalName`/`name` to `[A-Za-z0-9._-]`, reject empty names, `.`/`..`, and separators; use a random UUID for the temp directory; resolve the final path and assert it is inside `extDir`; use `runInShell: false` and pass arguments directly. +- **Effort:** Low + +### MEDIUM + +#### S-06: Book download path is built from remote `md5` without validation +- **Category:** Security / path traversal +- **Files:** `lib/services/books/book_download_service.dart:41-45,74`, `lib/models/book/book_result.dart:47` +- **Description:** `BookResult.md5` comes from remote JSON and is used directly in `p.join(dir.path, '$md5.$ext')`. The file type is stripped of dots, but `md5` is not validated. The same path is used for download, existence checks, and deletion. +- **Impact:** A malicious or compromised book API can cause writes and deletes outside the books directory, with the downloaded bytes appended by `.epub`/`.pdf`. Impact is limited to files with that suffix and requires a hostile metadata source, hence Medium. +- **Remediation:** Require `^[a-fA-F0-9]{32}$` for `md5` (or a URL-safe opaque id with a strict allowlist), then canonicalize and confirm the path stays under the books directory. +- **Effort:** Low + +#### S-07: IPTV credentials are stored in plaintext, sent over cleartext, and logged +- **Category:** Security / credential handling +- **OWASP:** A02:2021 Cryptographic Failures, A09:2021 Security Logging Failures +- **Files:** `lib/services/iptv/iptv_storage.dart:44-52,329-347`, `lib/services/iptv/iptv_network.dart:16-40,93-94,120-122`, `lib/pages/player/player_screen.dart:1081-1086,1154` +- **Description:** Portal URL, username, and password are serialized as JSON into `SharedPreferences` under `pt_iptv_verified_portals`; password-derived pref keys are stored in clear (`'${p.url}|${p.username}|${p.password}'`). Xtream credentials are interpolated into query strings, and `http://` portals are accepted. Player startup logs the raw source URL and resolved debrid URL, both of which can contain credentials. +- **Impact:** Anyone with backup, root, ADB, or filesystem access can read the credentials. A network observer can capture them from an HTTP portal. Logcat/device logs may retain them indefinitely. +- **Remediation:** Move portal credentials to `SecureStore`; use a salted hash for pref keys, not the raw password; require HTTPS or warn explicitly; remove URL logging or redact userinfo/query/path segments. +- **Effort:** Medium + +#### S-08: Provider secrets are compiled into shipped binaries +- **Category:** Security / secret management +- **OWASP:** A05:2021 Security Misconfiguration +- **Files:** `lib/services/config/env_service.dart:74-109`, `.github/workflows/build.yml:42-50,166-174,253-261,353-361,429-437,504-512` +- **Description:** CI writes `.env` from GitHub Secrets and passes `--dart-define-from-file=.env` to every release build. `EnvService` reads Trakt client ID/secret, Simkl client ID/secret, TMDb API key, and Discord app ID from compile-time defines. To the extent those fields are populated in CI, they are compiled into the shipped binary; AOT compilation does not hide these strings. +- **Impact:** Anyone who downloads the app can extract the values. A Trakt/Simkl client secret allows impersonating the application; TMDb/Discord keys allow quota abuse. Public client IDs are not secret, but client secrets are. +- **Remediation:** Treat installed-app client IDs as public and remove client secrets entirely (use PKCE/public clients). Move any privileged calls behind a small backend proxy. Rotate any secret that has shipped. Keep `.env` out of CI logs and artifacts. +- **Effort:** Medium + +#### S-09: Cleartext traffic is allowed globally on Android, iOS, and macOS +- **Category:** Security / transport policy +- **OWASP:** A02:2021 Cryptographic Failures +- **Files:** `android/app/src/main/AndroidManifest.xml:59`, `ios/Runner/Info.plist:7-10`, `macos/Runner/Info.plist:7-10` +- **Description:** Android sets `android:usesCleartextTraffic="true"`; both Apple targets set `NSAllowsArbitraryLoads` plus `NSAllowsLocalNetworking`. The comments in the manifest indicate this is intentional for IPTV and arbitrary stream sources. +- **Impact:** Every HTTP URL accepted by addons, IPTV portals, or extractors is permitted, including credential-bearing requests. It removes the platform's defense-in-depth and makes downgrade attacks invisible. +- **Remediation:** Use an Android `network_security_config.xml` that permits cleartext only for explicitly user-added local/IPTV hosts (or only in debug), and per-domain ATS exceptions rather than a global `NSAllowsArbitraryLoads`. Enforce HTTPS for first-party APIs. +- **Effort:** Medium + +#### S-10: Remote response bodies are unbounded +- **Category:** Security / denial of service +- **OWASP:** A04:2021 Insecure Design +- **Files:** `lib/services/iptv/iptv_network.dart:16-33,1105-1109`, `lib/services/subtitles/subtitle_extractor.dart:70-72,110-118`, `lib/services/stream/stream_service.dart:528-538`, `lib/services/books/book_download_service.dart:96-136` +- **Description:** Several clients read an entire response into memory (`bytesToString()`, `response.body`, `jsonDecode`) with only timeouts, no size cap. Zip/gzip subtitle archives are decoded with no decompressed-size limit. The ~50 scraper site clients follow the same pattern. +- **Impact:** A hostile or compromised portal, addon, subtitle provider, or scraper target can return a multi-gigabyte body or a compression bomb, causing memory exhaustion, ANR, or a crash. On mobile this is an easy remote DoS. +- **Remediation:** Cap `Content-Length` and streamed bytes (for example 2-10 MB for JSON/HTML, 50 MB for books/subtitles), enforce decompressed-size limits before decoding, and fail closed. Add per-host concurrency limits for scraper fan-out. +- **Effort:** Medium + +#### S-11: Remote playlists, addons, and APIs control request targets and receive credentials +- **Category:** Security / SSRF, credential exposure +- **OWASP:** A10:2021 Server-Side Request Forgery, A01:2021 Broken Access Control +- **Files:** `lib/services/download/hls_download_engine.dart:228,261-269,287`, `lib/services/music/convertytmp3_client.dart:26-47,56-60`, `lib/services/addon/addon_url_resolver.dart:32-46`, `lib/services/stream/stream_service.dart:524-526`, `lib/models/stream/stream_model.dart:90-101`, `lib/pages/player/watch_screen.dart:314-338,1292-1294` +- **Description:** HLS segment/key/init URIs are resolved with `baseUri.resolve(...)` and fetched, so a playlist can target loopback/LAN addresses. The music conversion API returns `convertURL`/`redirectURL`/`progressURL`/`downloadURL`, and the app follows them while attaching `Authorization: Bearer `. Addon base URLs get `{realdebrid}`/`{torbox}` placeholders replaced with the user's saved key, and addon-supplied `headers`/`proxyHeaders.request` are applied verbatim, including `Host`, `Cookie`, and `Authorization`. `externalUrl` is passed to the OS with no scheme allowlist. +- **Impact:** A malicious addon, playlist, or conversion response can make the app fetch internal services (SSRF), exfiltrate a debrid bearer token, override request headers, or invoke arbitrary OS URL schemes. These require a user-installed addon or a hostile stream source, hence Medium. +- **Remediation:** For HLS, allow only `http`/`https` and reject private, loopback, and link-local addresses unless the original source was local. Do not substitute secrets into addon-supplied URLs; proxy debrid requests through a first-party endpoint or require the user to configure the full URL. Allowlist headers (`User-Agent`, `Referer`, `Origin`, `Range`, `Accept`) and strip `Host`/`Cookie`/`Authorization`. Allow only `https`/`http`/`stremio` schemes for external opens. +- **Effort:** Medium + +### LOW + +#### S-12: MegaProxy token is predictable and CORS is wildcard +- **Files:** `lib/services/anime_arabic/mega_proxy.dart:34-87,131-140` +- **Description:** The loopback server issues tokens as `microsecondsSinceEpoch_seq` and serves `/v/.mp4` with `Access-Control-Allow-Origin: *`. The `_files` map has no eviction. +- **Impact:** A local process, or a web page that guesses the port/token, can fetch proxied streams while the proxy is alive. Content is transient and not highly sensitive, so Low. +- **Remediation:** Use `Random.secure()` for 128-bit tokens, check the `Origin` header, restrict CORS to the app's own origin (or omit it for native clients), and evict entries after playback ends. +- **Effort:** Low + +#### S-13: Hardcoded Qobuz session token in source +- **Files:** `lib/services/music/qobuz_music_service.dart:53-57` +- **Description:** A worker endpoint URL contains a literal session token in its path (`/u/4opn…`). The full value is in the source at the line above. +- **Impact:** The token is public in the repository and in shipped binaries. Impact depends on what the worker authorizes; at minimum it is an uncontrolled shared credential. +- **Remediation:** Remove the literal, rotate it, and obtain a per-install token from the service. If the endpoint is intentionally public, document that and drop the token-shaped path. +- **Effort:** Low + +#### S-14: Debug logging leaks cookies and stream URLs +- **Files:** `lib/services/scraper/sites/hindmoviez.dart:681,694`, `lib/services/scraper/sites/fourkhdhub.dart:92,142,174,221,228,236`, `lib/services/subtitles/subtitle_extractor.dart:31-40` +- **Description:** `print`/`debugPrint` statements emit `set-cookie` headers, session cookies, search URLs, redirect URLs, and stream URLs. `print` is not stripped in release builds. +- **Impact:** Cookies and signed URLs persist in logcat/console logs, where other apps or support tooling can read them. Low because it needs log access. +- **Remediation:** Remove the cookie/URL prints or route them through a redacting logger gated on `kDebugMode`. Never log `Set-Cookie` or query strings that contain tokens. +- **Effort:** Low + +#### S-15: Persisted download task paths are trusted on reload +- **Files:** `lib/services/download/download_service.dart:58-95,376,592-610` +- **Description:** `zplay_download_tasks.json` stores `targetFilePath`; the service writes, renames, copies, and deletes that path without re-validating it is under the downloads directory. +- **Impact:** A local attacker who can edit app storage can turn a queued task into an arbitrary write/delete. Low because it requires local access. +- **Remediation:** Validate persisted paths against the downloads root on load, and regenerate them from task id/name rather than trusting the stored absolute path. +- **Effort:** Low + +#### S-16: TorrServer is started without an explicit bind address or auth +- **Files:** `lib/services/stream/torrent_stream_service.dart:184-198`; package args at `torrserver_flutter-0.0.7/lib/src/torrserver_controller_subprocess.dart:70-76` +- **Description:** The app calls `_controller.start()` with no `--ip` or `--httpauth`. The client connects to `127.0.0.1`, but the server's bind address is determined by the upstream binary's default. The package passes only `-p -d `. +- **Impact:** If TorrServer defaults to `0.0.0.0`, any LAN peer can reach its unauthenticated API and add torrents or read cached pieces. This is conditional on the upstream default, so Low/informational until verified. +- **Remediation:** Pass `--ip 127.0.0.1` (and `--httpauth` if the version supports it) via `extraArgs`, then verify with `netstat`/`ss` on each platform. +- **Effort:** Low + +### Notes / Accepted Risks + +- `android/app/src/main/res/xml/filepaths.xml:3` exposes only `ota_update/` through the FileProvider. Scoped correctly; keep it that way. +- The trailer WebView uses `JavaScriptMode.unrestricted`, but it loads a local wrapper, has no JavaScript channel, and escapes the video key (`trailer_modal.dart:272-285`). Current risk is low; adding a `NavigationDelegate` that blocks non-local navigations would harden it. +- `http://` addon URLs are accepted (`addon_manager.dart:336-386`). This is user-initiated, but it should warn or require confirmation. + +## Performance Findings + +The prior audit's already-fixed items were re-checked and are not repeated here. Notably, startup services were split after `runApp` (`lib/main.dart:107-115`), the home carousel and audiobook timers are cancelled in `dispose`, and `memCacheWidth` is now used at 60 sites. The remaining hot spots are per-event work, not missing micro-optimizations. + +### HIGH + +#### P-01: EPUB reader persists progress on every scroll event +- **Files:** `lib/pages/books/epub_reader_page.dart:56,70-72,171-194`, `lib/services/books/continue_reading_service.dart:52-69`, `lib/services/storage/json_document_store.dart:123-137,209-219` +- **Description:** `_scrollController.addListener(_onScrollChanged)` calls `_saveProgress()` for every scroll notification. Each save copies the reading list, notifies Home via `activeItems.value`, then enqueues a store write that parses the existing document, computes a SHA-256 checksum, and performs two `SharedPreferences` writes (backup + primary). +- **Impact:** At 60 scroll events/s this is ~60 JSON decode/encode + SHA-256 + 2 platform-channel writes per second, serialized into an ever-growing `_writeChains` queue. It causes reader stutter, battery drain, and I/O churn, and it wakes Home on every tick. +- **Evidence:** + ```dart + // epub_reader_page.dart:70-72 + void _onScrollChanged() { + _saveProgress(); + } + ``` +- **Remediation:** Save on `dispose`, chapter change, and a 2-second debounce/`ScrollEndNotification` instead of every scroll tick. In `JsonDocumentStore`, coalesce writes with a dirty flag and flush every 5-10 seconds. +- **Effort:** Low + +#### P-02: Music page rebuilds its entire tree on every player position tick +- **Files:** `lib/pages/music/music_page.dart:135,163-165,765`, `lib/services/music/music_player_controller.dart:212-216` +- **Description:** The controller calls `notifyListeners()` from `player.stream.position.listen`. `MusicPage` responds with an unconditional `setState(() {})`, rebuilding the ~5,000-line page, its tabs, home view, and expanded player. Only the seek bar and lyrics need position updates. +- **Impact:** Sub-second full-page builds and layout passes, even when the music tab is hidden in the shell's `IndexedStack`. This is the main reason the music tab is the heaviest screen. +- **Remediation:** Expose position as a separate `ValueNotifier` and wrap only the seek bar/lyrics in `ValueListenableBuilder`. Throttle position notifications to 4-10 Hz. +- **Effort:** Medium + +#### P-03: Music page nests shrink-wrapped scrollables inside its page `ListView` +- **Files:** `lib/pages/music/music_page.dart:645,1166,1223,1256,1307,1375,1647,1719,3508` +- **Description:** Multiple `ListView`/`GridView` use `shrinkWrap: true` with `NeverScrollableScrollPhysics` inside the page scroller. `shrinkWrap` forces every child to be built and laid out on each parent build. +- **Impact:** Combined with P-02, the full music catalog is rebuilt and laid out many times per second. It also defeats lazy list building. +- **Remediation:** Convert the page to a `CustomScrollView` with slivers, or give the inner grids bounded heights and keep them lazy. Avoid `shrinkWrap` for lists with more than a screen of content. +- **Effort:** Medium + +#### P-04: Hidden Home page rebuilds and makes four network calls while the user is elsewhere +- **Files:** `lib/pages/home/home_page.dart:473-476,646-657,787-816`, `lib/shell/app_shell.dart:636` +- **Description:** Home listens to `MyListService.items` and `ContinueWatchingService.activeItems` for its entire lifetime. Every progress tick fires a post-frame `setState`, and an 800 ms debounce then calls `_refreshSimilarSections()`, which fans out four `forceRefresh` requests (list, continue-watching, Trakt, Simkl). The shell keeps Home mounted in an `IndexedStack`. +- **Impact:** Reading a book or watching a video triggers continuous background rebuilds and recurring network traffic, competing with playback and wasting battery/data. +- **Remediation:** Gate the expensive listener path and the four-request refresh on the active shell slot (`_shellController?.current.value == ShellSlot.home`), and mark the page dirty instead of rebuilding it while hidden. The existing `_onSlotChanged` handler (`home_page.dart:516-521`) already runs on return to Home, so flush the dirty state there. Cache the four sections between visits. +- **Effort:** Low + +### MEDIUM + +#### P-05: Five-second progress timers rewrite the same JSON document +- **Files:** `lib/pages/player/player_screen.dart:1324-1325`, `lib/pages/audiobooks/audiobook_player_screen.dart:99-101`, `lib/services/books/continue_reading_service.dart:52-69` +- **Description:** Both players save progress every 5 seconds, landing in the same parse + checksum + two-write path as P-01 and notifying Home. +- **Impact:** 12 writes/minute per active player, each with a SHA-256 and two platform-channel writes. It is unnecessary because a crash loses at most a few seconds of playback. +- **Remediation:** Save every 30 seconds, on pause, on seek, and on dispose. Let the store coalesce writes. +- **Effort:** Low + +#### P-06: Manga reader precaches an entire chapter and saves on a fixed 900 px pitch +- **Files:** `lib/pages/manga/manga_reader_page.dart:172-192,228-239` +- **Description:** On chapter open, the reader queues `precacheImage` for every page thumbnail in the chapter (40-100 images) in one synchronous loop, plus ±2 full-width pages. In webtoon mode it estimates the current page as `offset / 900` and saves progress whenever that estimate changes. +- **Impact:** Visible freeze and memory spike on chapter open from decoding the whole chapter at once. The fixed pitch does not match variable image heights, so the page indicator is often wrong and progress saves fire repeatedly during continuous scroll. +- **Remediation:** Precache a window of ~8 pages around the current page, not the whole chapter. Derive page extents from image aspect ratios (or use `ScrollablePositionedList`), and debounce the progress save. +- **Effort:** Medium + +#### P-07: Synchronous filesystem stats inside list item builders +- **Files:** `lib/pages/audiobooks/audiobooks_page.dart:1193,1302`, `lib/pages/audiobooks/generate_audiobook_screen.dart:1027,1260`, `lib/pages/music/music_page.dart:5022`, `lib/pages/downloads/downloads_page.dart:60` +- **Description:** Item widgets call `File(path).existsSync()` during build to decide whether to show a local cover. +- **Impact:** One blocking `stat` per row per rebuild. On a scrolled grid this can be dozens to hundreds of syscalls per frame on Android/Windows. +- **Remediation:** Resolve existing cover paths once when the list/jobs load into a `Set`, pass the result into the item widgets, and refresh on file-system events or after downloads complete. +- **Effort:** Low + +#### P-08: Two independent update checks race at startup +- **Files:** `lib/main.dart:195-201`, `lib/pages/home/home_page.dart:488-491,578-606` +- **Description:** `ZPlayApp` schedules `_checkForUpdates()` 1.5 s after the first frame; Home's `_runStartupDialogs()` also calls `_checkAutoUpdate()`. Each has its own static guard, so they are not mutually exclusive. +- **Impact:** Two release-manifest requests in the first two seconds and two dialog code paths that can race, producing duplicate prompts. +- **Remediation:** Make the app root the single owner of update checks; remove the Home path. Keep one "already checked this session" guard. +- **Effort:** Low + +#### P-09: IPTV portal search rebuilds the full browser on every keystroke +- **Files:** `lib/pages/iptv/iptv_portal_browser_page.dart:932,1124` +- **Description:** `onChanged: (v) => setState(() => _searchQuery = v)` has no debounce. Each keystroke rebuilds both panes and re-runs channel/category projections over portals that routinely carry thousands of streams. +- **Impact:** Typing in search becomes janky on TV/low-end hardware. +- **Remediation:** Debounce 150-250 ms and filter into a cached list; keep the text field's immediate value local to the field. +- **Effort:** Low + +### LOW + +#### P-10: Player settings extraction blocks the first frame on first launch +- **Files:** `lib/main.dart:90-92`, `lib/services/player/player_settings.dart:619-691,694-819` +- **Description:** `PlayerSettings.initialize()` is awaited before `runApp`; it extracts the libass font and 11 shader files (9 Anime4K + 2 FSR) to disk on first run (subsequent runs only stat them). +- **Impact:** First-launch startup does ~12 asset loads and writes before any UI appears. Small but avoidable. +- **Remediation:** Defer extraction to after `runApp` (idle callback or first player open); the paths are only needed for playback/subtitles. +- **Effort:** Low + +#### P-11: Expanded music player decodes a full-resolution cover under a 50-sigma blur +- **Files:** `lib/pages/music/music_page.dart:4193-4202` +- **Description:** A `CachedNetworkImage` with no `memCacheWidth` is placed under `BackdropFilter(ImageFilter.blur(sigmaX: 50, sigmaY: 50))` and 0.85 opacity. +- **Impact:** A 1400×1400 cover is fully decoded and mipmapped for a backdrop that is visually indistinguishable from a 256 px version, then blurred each frame the modal is open. +- **Remediation:** Set `memCacheWidth: 256` (and `memCacheHeight: 256`) on the backdrop image. +- **Effort:** Low + +#### P-12: Unhandled Trakt future, uncached `Image.network`, and no video repaint boundary +- **Files:** `lib/pages/home/home_page.dart:907-911`, `lib/widgets/player/player_loading_overlay.dart:145-149,178-183`, `lib/pages/player/player_screen.dart:3866-3884,3993,4023` +- **Description:** `unawaited(traktPublic.then(...))` has no `catchError`, so a failure becomes an unhandled async error and the loaded flag is never set. The loading overlay uses `Image.network` (memory cache only), so posters re-download across runs. `mk.Video` has no `RepaintBoundary`; on the mpv path it is a `Texture` with its own layer, so the win is mainly on the Media3/platform-view branch. +- **Impact:** Debug noise/retry loops and repeated poster downloads are the concrete costs; the missing boundary is minor on the mpv path and matters more for platform-view composition. Each is small on its own. +- **Remediation:** Add `catchError` and set the loaded flag in `finally`; use `CachedNetworkImage` for posters; wrap the video surface (or at least the overlay) in a `RepaintBoundary`. +- **Effort:** Low + +## Architecture & Trust Boundaries + +``` +UI pages / widgets + │ + ├── SecureStore (keystore) ← tokens, debrid keys (good) + ├── SharedPreferences / files ← IPTV creds, reading progress (plaintext) + │ + ├── AddonManager ──► addon baseUrl ──► {debrid} key substitution + │ └── headers / externalUrl passed through + ├── IptvClient ─────► portal (http/https) + credentials in query string + ├── BookDownloadService ──► remote metadata (md5) + TLS verification OFF + ├── CloudStreamManager ──► repo metadata ──► .cs3 ──► DEX/JVM in-process + │ └── runtime downloads (no hash) ──► zip/tar extraction (zip-slip) + ├── HlsDownloadEngine ──► playlist-controlled URIs (SSRF surface) + ├── Music conversion API ──► server-controlled URLs + Bearer key + ├── AppUpdaterService ──► GitHub release ──► auto-run .exe / install APK + └── Loopback services ──► MegaProxy (CORS *), TorrServer (bind TBD) +``` + +The first-party core (UI, settings, storage, backup) is reasonably layered. Risk concentrates where remote metadata crosses into filesystem paths, process execution, or credential-bearing requests. The two boundaries that deserve design-level work are the CloudStream runtime and the updater. + +## Dependency Summary + +OSV `querybatch` against the Pub ecosystem was run for 140 hosted packages resolved in `pubspec.lock` on 2026-10-24. **No advisories were returned.** + +| Package | Resolved | Notes | +|---------|----------|-------| +| `archive` | 4.2.0 | Used for runtime/subtitle archives; extraction needs containment (S-02). | +| `http` | 1.6.0 | Clean. | +| `webview_flutter` | 4.14.1 | Trailer modal only; no JS channels. | +| `media_kit` / `media_kit_video` | 1.1.11 (git pin `11d02cb`) | Pinned fork; reproducible, but fork maintenance is a supply-chain consideration. | +| `pdfrx` | 2.6.1 | Clean. | +| `ota_update` | 7.1.0 | Checksum support exists but is not used (S-04). | +| `flutter_secure_storage` | 10.3.4 | Keystore-backed; good. | +| `torrserver_flutter` | 0.0.7 | Pre-1.0 engine wrapper; verify bind/auth defaults (S-16). | +| `pointycastle` | 3.9.1 | Profile encryption; clean. | +| `cached_network_image` | 3.4.1 | 92 uses, 60 with `memCacheWidth`. | + +License compliance was not scanned; that is a separate pass. No unused-dependency analysis was performed beyond the resolution check. The `media_kit` modules are git-pinned rather than hosted, so they are not covered by the Pub advisory feed; the pinned commit was not reviewed beyond its integration points. + +## Recommendations + +### Immediate (this sprint) + +1. Add SHA-256 verification for updater downloads and CloudStream runtime artifacts; pin versions instead of `releases/latest`. On Windows, verify a code signature before running an installer (S-01, S-04). +2. Fix zip-slip in both extractors with canonical-path containment (S-02). +3. Sanitize `internalName`/`name` and book `md5`, use UUID temp directories, and remove `runInShell` (S-05, S-06). +4. Delete the five `badCertificateCallback` overrides and scope any unavoidable exception to one host (S-03). +5. Redact URL logging and move IPTV credentials to `SecureStore` (S-07, S-14). +6. Throttle EPUB progress saves, scope the music position listener, and gate Home refresh to the active tab (P-01, P-02, P-04). + +### Short-term (this quarter) + +1. Replace global cleartext/ATS bypasses with per-domain network security configuration (S-09). +2. Harden the addon boundary: strip secret-bearing headers, stop substituting debrid keys into addon URLs, allowlist external schemes (S-11). +3. Add response-size and decompression limits to IPTV, scrapers, subtitles, and stream fetching (S-10). +4. Remove provider client secrets from the binary; use PKCE/public clients or a backend proxy (S-08). +5. Coalesce progress writes in `JsonDocumentStore`; bound manga precache; cache `existsSync` results; debounce IPTV search; make the updater single-owner (P-05 through P-09). +6. Replace the MegaProxy token with `Random.secure()` and restrict CORS (S-12). + +### Long-term (backlog) + +1. Move CloudStream plugin execution out-of-process behind a narrow, permissioned IPC surface; sign extensions and publish a clear risk disclosure. If that is not viable, reconsider shipping the marketplace at all (S-01). +2. Code-sign and notarize Windows/macOS builds; verify signatures in the updater (S-04). +3. Add a performance regression budget for the four hot screens (frame time, writes/minute, network calls while idle). +4. Add a CI security gate: `flutter analyze`, OSV scan, secret scan, and a check that `badCertificateCallback`/`usesCleartextTraffic` do not reappear. + +## Appendix + +### Methodology + +- Read-only static review of 421 Dart files (172,626 lines) plus Android Kotlin, manifests, plists, and CI workflows at commit `a239d9e`. +- The codebase exceeds the skill's single-context budget, so a map-reduce-style review was used instead of one 1M-token load: repository reconnaissance, secret/credential review, CloudStream/updater/IPTV/addon/backup deep reads, and two parallel inventories (security sinks, performance hot spots), followed by source-level verification of every reported finding. +- Cross-checked every reported finding against the source before inclusion. One performance claim (off-screen ambient animation) was rejected because `TickerMode` already pauses inactive shell slots (`lib/shell/app_shell.dart:525-526`). +- Dependency check: OSV `querybatch` for 140 hosted Pub packages. +- Prior audit claims were re-tested; fixed items were excluded from the findings. + +### Limitations + +- No dynamic testing, fuzzing, or penetration test was performed. +- No device profiling; performance findings are based on code paths and call frequency, not measured frame times. +- Platform plugin internals (notably `torrserver_flutter` and the CloudStream runtime host) were only reviewed at the integration boundary. +- Some findings depend on runtime configuration or upstream defaults (S-16, the impact of S-01's marketplace). +- License compliance and unused-dependency analysis were out of scope. + +### Key Files Reviewed + +`lib/main.dart`, `lib/shell/app_shell.dart`, `lib/pages/home/home_page.dart`, `lib/pages/music/music_page.dart`, `lib/pages/books/epub_reader_page.dart`, `lib/pages/manga/manga_reader_page.dart`, `lib/pages/player/player_screen.dart`, `lib/services/cloudstream/**`, `lib/services/books/**`, `lib/services/iptv/**`, `lib/services/addon/**`, `lib/services/stream/**`, `lib/services/download/**`, `lib/services/updater/**`, `lib/services/config/env_service.dart`, `lib/services/storage/json_document_store.dart`, `android/app/src/main/kotlin/io/github/tzero86/zplay/CloudStreamNativeBridge.kt`, `android/app/src/main/AndroidManifest.xml`, `ios/Runner/Info.plist`, `macos/Runner/Info.plist`, `.github/workflows/build.yml`, `.github/workflows/quality.yml`, `pubspec.yaml`, `pubspec.lock`. From 27fb82edc5871f416c72cb105df77be37d3f8f68 Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 09:21:55 -0300 Subject: [PATCH 02/19] security: contain archive extraction paths --- .../tzero86/zplay/CloudStreamNativeBridge.kt | 7 +- .../runtime/cloudstream_downloader.dart | 11 +-- lib/utils/security/safe_paths.dart | 58 ++++++++++++++++ test/utils/security/safe_paths_test.dart | 69 +++++++++++++++++++ 4 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 lib/utils/security/safe_paths.dart create mode 100644 test/utils/security/safe_paths_test.dart diff --git a/android/app/src/main/kotlin/io/github/tzero86/zplay/CloudStreamNativeBridge.kt b/android/app/src/main/kotlin/io/github/tzero86/zplay/CloudStreamNativeBridge.kt index ac94cb33..c8851fae 100644 --- a/android/app/src/main/kotlin/io/github/tzero86/zplay/CloudStreamNativeBridge.kt +++ b/android/app/src/main/kotlin/io/github/tzero86/zplay/CloudStreamNativeBridge.kt @@ -190,7 +190,12 @@ class CloudStreamNativeBridge(private val context: Context, private val activity val entry = entries.nextElement() if (entry.name.startsWith(prefix) && !entry.isDirectory) { val libName = entry.name.substring(prefix.length) - val outFile = File(targetDir, libName) + val root = targetDir.canonicalFile + val outFile = File(root, libName).canonicalFile + if (!outFile.path.startsWith(root.path + File.separator)) { + Log.w(TAG, "Skipping native library outside target: ${entry.name}") + continue + } if (!outFile.exists() || outFile.length() != entry.size) { if (outFile.exists()) outFile.setWritable(true) zip.getInputStream(entry).use { input -> diff --git a/lib/services/cloudstream/runtime/cloudstream_downloader.dart b/lib/services/cloudstream/runtime/cloudstream_downloader.dart index d2b0b2c6..74447c70 100644 --- a/lib/services/cloudstream/runtime/cloudstream_downloader.dart +++ b/lib/services/cloudstream/runtime/cloudstream_downloader.dart @@ -4,6 +4,7 @@ import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:archive/archive.dart'; +import '../../../utils/security/safe_paths.dart'; import 'cloudstream_paths.dart'; class CloudStreamDownloader { @@ -255,13 +256,14 @@ class CloudStreamDownloader { final archive = ZipDecoder().decodeBytes(bytes); for (final file in archive) { final filename = file.name; + final targetPath = safeChildPath(targetDir, filename); if (file.isFile) { final data = file.content as List; - File(p.join(targetDir, filename)) + File(targetPath) ..createSync(recursive: true) ..writeAsBytesSync(data); } else { - Directory(p.join(targetDir, filename)).createSync(recursive: true); + Directory(targetPath).createSync(recursive: true); } } } else if (archivePath.endsWith('.tar.gz')) { @@ -270,13 +272,14 @@ class CloudStreamDownloader { final archive = TarDecoder().decodeBytes(gzipBytes); for (final file in archive) { final filename = file.name; + final targetPath = safeChildPath(targetDir, filename); if (file.isFile) { final data = file.content as List; - File(p.join(targetDir, filename)) + File(targetPath) ..createSync(recursive: true) ..writeAsBytesSync(data); } else { - Directory(p.join(targetDir, filename)).createSync(recursive: true); + Directory(targetPath).createSync(recursive: true); } } } diff --git a/lib/utils/security/safe_paths.dart b/lib/utils/security/safe_paths.dart new file mode 100644 index 00000000..e449e4a1 --- /dev/null +++ b/lib/utils/security/safe_paths.dart @@ -0,0 +1,58 @@ +import 'dart:math'; + +import 'package:path/path.dart' as p; + +/// Resolves [entryName] below [root], rejecting absolute paths and `..` +/// segments. Archive entry names are untrusted and must never escape the +/// extraction directory. +String safeChildPath(String root, String entryName) { + final normalizedEntry = entryName.replaceAll('\\', '/'); + if (normalizedEntry.isEmpty) { + throw const FormatException('Empty archive entry'); + } + if (p.isAbsolute(normalizedEntry) || + RegExp(r'^[A-Za-z]:').hasMatch(normalizedEntry)) { + throw FormatException('Absolute archive entry: $entryName'); + } + + final segments = normalizedEntry.split('/'); + if (segments.any((segment) => segment == '..' || segment.contains(':'))) { + throw FormatException('Archive entry escapes target directory: $entryName'); + } + + final rootPath = p.normalize(p.absolute(root)); + final targetPath = p.normalize(p.absolute(p.join(rootPath, normalizedEntry))); + if (p.equals(rootPath, targetPath) || !p.isWithin(rootPath, targetPath)) { + throw FormatException('Archive entry escapes target directory: $entryName'); + } + return targetPath; +} + +/// Makes a single path component safe for use below a known directory. +/// +/// This preserves readable names while removing path separators, Windows +/// reserved characters, control characters, and leading/trailing dots or +/// spaces. The result is never `.` or `..`. +String sanitizeFileComponent(String input, {String fallback = 'file'}) { + var value = input + .trim() + .replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1F]'), '_') + .replaceAll(RegExp(r'^[. ]+|[. ]+$'), ''); + if (value.length > 120) { + value = value.substring(0, 120); + } + if (value.isEmpty || value == '.' || value == '..') { + return fallback; + } + return value; +} + +/// Returns a cryptographically secure hex token. +String secureRandomHex(int byteCount) { + final random = Random.secure(); + final buffer = StringBuffer(); + for (var i = 0; i < byteCount; i++) { + buffer.write(random.nextInt(256).toRadixString(16).padLeft(2, '0')); + } + return buffer.toString(); +} diff --git a/test/utils/security/safe_paths_test.dart b/test/utils/security/safe_paths_test.dart new file mode 100644 index 00000000..a5287630 --- /dev/null +++ b/test/utils/security/safe_paths_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:zplay/utils/security/safe_paths.dart'; + +void main() { + final root = p.join( + p.separator == '\\' ? r'C:\tmp' : '/tmp', + 'zplay-safe-paths', + ); + + group('safeChildPath', () { + test('keeps nested entries under the root', () { + expect( + safeChildPath(root, 'lib/foo.dll'), + p.join(root, 'lib', 'foo.dll'), + ); + }); + + test('normalizes backslash separators', () { + expect( + safeChildPath(root, r'lib\foo.dll'), + p.join(root, 'lib', 'foo.dll'), + ); + }); + + test('rejects parent traversal', () { + expect(() => safeChildPath(root, '../evil'), throwsFormatException); + expect( + () => safeChildPath(root, 'lib/../../evil'), + throwsFormatException, + ); + }); + + test('rejects absolute paths and drive-relative paths', () { + expect(() => safeChildPath(root, '/etc/passwd'), throwsFormatException); + expect(() => safeChildPath(root, r'C:\evil'), throwsFormatException); + expect(() => safeChildPath(root, 'C:evil'), throwsFormatException); + }); + + test('rejects empty entries', () { + expect(() => safeChildPath(root, ''), throwsFormatException); + }); + }); + + group('sanitizeFileComponent', () { + test('removes path separators and reserved characters', () { + final sanitized = sanitizeFileComponent(r'..\evil/name?.jar'); + expect(sanitized, isNot(contains('/'))); + expect(sanitized, isNot(contains(r'\'))); + expect(sanitized, isNot(contains('?'))); + }); + + test('never returns a traversal component', () { + expect(sanitizeFileComponent('..'), 'file'); + expect(sanitizeFileComponent(' '), 'file'); + final traversal = sanitizeFileComponent('../../evil'); + expect(traversal, isNot('..')); + expect(traversal, isNot(contains('/'))); + }); + + test('uses the requested fallback', () { + expect(sanitizeFileComponent('', fallback: 'extension'), 'extension'); + }); + + test('keeps readable names', () { + expect(sanitizeFileComponent('My Extension 1.0'), 'My Extension 1.0'); + }); + }); +} From 81ef3e438f7b39e0fe95c7de126b5508beb347fa Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 09:24:49 -0300 Subject: [PATCH 03/19] security: sanitize cloudstream extension names --- .../cloudstream/cloudstream_manager.dart | 38 ++++++++++++------- lib/utils/security/safe_paths.dart | 2 +- test/utils/security/safe_paths_test.dart | 1 + 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/lib/services/cloudstream/cloudstream_manager.dart b/lib/services/cloudstream/cloudstream_manager.dart index a80c4b4d..214a227f 100644 --- a/lib/services/cloudstream/cloudstream_manager.dart +++ b/lib/services/cloudstream/cloudstream_manager.dart @@ -13,6 +13,7 @@ import '../../models/cloudstream/cloudstream_source.dart'; import '../../models/stream/stream_model.dart'; import '../../models/movie/movie_detail.dart'; import '../../models/movie/video.dart'; +import '../../utils/security/safe_paths.dart'; import 'runtime/cloudstream_downloader.dart'; import 'runtime/cloudstream_paths.dart'; import 'runtime/cloudstream_dispatcher.dart'; @@ -610,8 +611,13 @@ class CloudStreamManager { throw Exception('Extension does not contain a valid .cs3 download URL.'); } - final extName = source.internalName ?? source.name; - final tempDir = Directory(p.join(extDir.path, 'temp_$extName')); + final extName = sanitizeFileComponent( + source.internalName ?? source.name, + fallback: 'extension', + ); + final tempDir = Directory( + p.join(extDir.path, 'temp_${secureRandomHex(16)}'), + ); if (await tempDir.exists()) await tempDir.delete(recursive: true); await tempDir.create(recursive: true); @@ -713,13 +719,20 @@ class CloudStreamManager { } } + Set _extensionFileNames(CloudStreamSource source) { + return { + for (final name in [ + source.name, + if (source.internalName != null) source.internalName!, + ]) + sanitizeFileComponent(name, fallback: ''), + }..remove(''); + } + Future uninstallExtension(CloudStreamSource source) async { final extDir = await CloudStreamPaths.instance.extensionsDir; - final candidates = { - source.name, - if (source.internalName != null) source.internalName!, - }; + final candidates = _extensionFileNames(source); for (final baseName in candidates) { final jarFile = File(p.join(extDir.path, '$baseName.jar')); @@ -756,10 +769,7 @@ class CloudStreamManager { try { final extDir = await CloudStreamPaths.instance.extensionsDir; for (final source in sources) { - final candidates = { - source.name, - if (source.internalName != null) source.internalName!, - }; + final candidates = _extensionFileNames(source); for (final baseName in candidates) { final jarFile = File(p.join(extDir.path, '$baseName.jar')); @@ -812,10 +822,12 @@ class CloudStreamManager { env['JAVA_HOME'] = p.dirname(javaBinDir); final process = await Process.run( - dex2jarPath, - ['--force', dexPath, '-o', outJarPath], + Platform.isWindows ? 'cmd.exe' : dex2jarPath, + Platform.isWindows + ? ['/c', dex2jarPath, '--force', dexPath, '-o', outJarPath] + : ['--force', dexPath, '-o', outJarPath], environment: env, - runInShell: Platform.isWindows, + runInShell: false, ); if (process.exitCode != 0) { diff --git a/lib/utils/security/safe_paths.dart b/lib/utils/security/safe_paths.dart index e449e4a1..a4b0d554 100644 --- a/lib/utils/security/safe_paths.dart +++ b/lib/utils/security/safe_paths.dart @@ -36,7 +36,7 @@ String safeChildPath(String root, String entryName) { String sanitizeFileComponent(String input, {String fallback = 'file'}) { var value = input .trim() - .replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1F]'), '_') + .replaceAll(RegExp(r'[\\/:*?"<>|&^%!\x00-\x1F]'), '_') .replaceAll(RegExp(r'^[. ]+|[. ]+$'), ''); if (value.length > 120) { value = value.substring(0, 120); diff --git a/test/utils/security/safe_paths_test.dart b/test/utils/security/safe_paths_test.dart index a5287630..17fb3b51 100644 --- a/test/utils/security/safe_paths_test.dart +++ b/test/utils/security/safe_paths_test.dart @@ -48,6 +48,7 @@ void main() { expect(sanitized, isNot(contains('/'))); expect(sanitized, isNot(contains(r'\'))); expect(sanitized, isNot(contains('?'))); + expect(sanitizeFileComponent('a&b|c^d%e!f'), 'a_b_c_d_e_f'); }); test('never returns a traversal component', () { From 2551d6a5fcc4d739fd5c2757aa68131fff7ad49d Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 09:27:44 -0300 Subject: [PATCH 04/19] security: validate book ids and download paths --- lib/services/books/book_download_service.dart | 22 +++++++++++++++---- lib/services/download/download_service.dart | 14 ++++++++++++ lib/utils/security/safe_paths.dart | 8 +++++++ .../books/book_download_service_test.dart | 20 +++++++++++++++++ test/utils/security/safe_paths_test.dart | 12 ++++++++++ 5 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 test/services/books/book_download_service_test.dart diff --git a/lib/services/books/book_download_service.dart b/lib/services/books/book_download_service.dart index 08cc3780..7a86aae4 100644 --- a/lib/services/books/book_download_service.dart +++ b/lib/services/books/book_download_service.dart @@ -5,6 +5,8 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../../models/book/book_result.dart'; +import '../../utils/security/safe_paths.dart'; + class BookDownloadService { static final BookDownloadService instance = BookDownloadService._(); BookDownloadService._(); @@ -13,6 +15,8 @@ class BookDownloadService { final ValueNotifier> progressNotifier = ValueNotifier>({}); + static final RegExp _md5Pattern = RegExp(r'^[0-9a-fA-F]{32}$'); + Directory? _booksDirectory; Future getBooksDirectory() async { @@ -39,9 +43,16 @@ class BookDownloadService { /// Returns the expected local file path for a book by its md5 and filetype. Future getBookFile(String md5, String filetype) async { + final normalizedMd5 = md5.trim(); + if (!_md5Pattern.hasMatch(normalizedMd5)) { + throw ArgumentError.value(md5, 'md5', 'Expected a 32-character hex id'); + } final dir = await getBooksDirectory(); - final ext = filetype.replaceAll('.', '').toLowerCase(); - return File(p.join(dir.path, '$md5.$ext')); + final ext = sanitizeFileComponent( + filetype.replaceAll('.', '').toLowerCase(), + fallback: 'bin', + ); + return File(p.join(dir.path, '$normalizedMd5.$ext')); } /// Checks if the book is already downloaded and exists on disk. @@ -68,8 +79,11 @@ class BookDownloadService { BookResult book, { void Function(double progress)? onProgress, }) async { - final md5 = book.md5; - if (md5.isEmpty) return null; + final md5 = book.md5.trim(); + if (!_md5Pattern.hasMatch(md5)) { + debugPrint('[BookDownloadService] Refusing book with invalid md5'); + return null; + } final targetFile = await getBookFile(md5, book.bookFiletype); diff --git a/lib/services/download/download_service.dart b/lib/services/download/download_service.dart index a8e46727..47404a54 100644 --- a/lib/services/download/download_service.dart +++ b/lib/services/download/download_service.dart @@ -10,6 +10,7 @@ import '../../models/download/download_task_model.dart'; import '../../models/stream/stream_model.dart'; import '../../utils/download/download_path_helper.dart'; import '../../utils/platform/storage_space_helper.dart'; +import '../../utils/security/safe_paths.dart'; import '../debrid/debrid_service.dart'; import '../stream/torrent_stream_service.dart'; import 'hls_download_engine.dart'; @@ -65,10 +66,23 @@ class DownloadService { final List jsonList = jsonDecode(content); final tasks = []; + final downloadsRoot = + await DownloadPathHelper.getDownloadsDirectoryPath(); for (final item in jsonList) { if (item is Map) { var task = DownloadTask.fromJson(item); + if (!isWithinDirectory(downloadsRoot, task.targetFilePath)) { + debugPrint( + '[DownloadService] Rejected download path outside $downloadsRoot', + ); + task = task.copyWith( + status: DownloadStatus.failed, + error: 'Invalid download path', + ); + tasks.add(task); + continue; + } // If app was terminated while downloading, set state to paused if (task.status == DownloadStatus.downloading || task.status == DownloadStatus.queued) { task = task.copyWith(status: DownloadStatus.paused); diff --git a/lib/utils/security/safe_paths.dart b/lib/utils/security/safe_paths.dart index a4b0d554..558b5bbb 100644 --- a/lib/utils/security/safe_paths.dart +++ b/lib/utils/security/safe_paths.dart @@ -2,6 +2,14 @@ import 'dart:math'; import 'package:path/path.dart' as p; +/// Returns true when [candidate] resolves to a path strictly inside [root]. +bool isWithinDirectory(String root, String candidate) { + if (candidate.trim().isEmpty) return false; + final rootPath = p.normalize(p.absolute(root)); + final candidatePath = p.normalize(p.absolute(candidate)); + return p.isWithin(rootPath, candidatePath); +} + /// Resolves [entryName] below [root], rejecting absolute paths and `..` /// segments. Archive entry names are untrusted and must never escape the /// extraction directory. diff --git a/test/services/books/book_download_service_test.dart b/test/services/books/book_download_service_test.dart new file mode 100644 index 00000000..c163971f --- /dev/null +++ b/test/services/books/book_download_service_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zplay/services/books/book_download_service.dart'; + +void main() { + group('BookDownloadService.getBookFile', () { + test('rejects a traversal-shaped id before touching storage', () async { + await expectLater( + BookDownloadService.instance.getBookFile('../../evil', 'epub'), + throwsArgumentError, + ); + }); + + test('rejects short ids', () async { + await expectLater( + BookDownloadService.instance.getBookFile('abc123', 'epub'), + throwsArgumentError, + ); + }); + }); +} diff --git a/test/utils/security/safe_paths_test.dart b/test/utils/security/safe_paths_test.dart index 17fb3b51..5906e573 100644 --- a/test/utils/security/safe_paths_test.dart +++ b/test/utils/security/safe_paths_test.dart @@ -8,6 +8,18 @@ void main() { 'zplay-safe-paths', ); + group('isWithinDirectory', () { + test('accepts nested paths', () { + expect(isWithinDirectory(root, p.join(root, 'movie.mkv')), isTrue); + }); + + test('rejects the root itself and siblings', () { + expect(isWithinDirectory(root, root), isFalse); + expect(isWithinDirectory(root, p.join(root, '..', 'other')), isFalse); + expect(isWithinDirectory(root, ''), isFalse); + }); + }); + group('safeChildPath', () { test('keeps nested entries under the root', () { expect( From 35f1e806300fea7bd9c5dfeaf87ff52b250e487d Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 09:28:42 -0300 Subject: [PATCH 05/19] security: restore TLS certificate validation --- lib/services/anime/extractors/tryembed_extractor.dart | 1 - lib/services/books/book_download_service.dart | 1 - lib/services/music/music_download_service.dart | 6 ++---- lib/services/subtitles/providers/subtitlecat_provider.dart | 3 +-- lib/services/subtitles/subtitlecat_service.dart | 3 +-- 5 files changed, 4 insertions(+), 10 deletions(-) diff --git a/lib/services/anime/extractors/tryembed_extractor.dart b/lib/services/anime/extractors/tryembed_extractor.dart index 05cf2ea0..114f7a12 100644 --- a/lib/services/anime/extractors/tryembed_extractor.dart +++ b/lib/services/anime/extractors/tryembed_extractor.dart @@ -54,7 +54,6 @@ class TryEmbedExtractor { 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36'; final HttpClient _httpClient = HttpClient() - ..badCertificateCallback = ((cert, host, port) => true) ..connectionTimeout = const Duration(seconds: 10); /// Scrapes the video stream and subtitles from TryEmbed for an Anilist Anime ID and Episode. diff --git a/lib/services/books/book_download_service.dart b/lib/services/books/book_download_service.dart index 7a86aae4..63c83c1c 100644 --- a/lib/services/books/book_download_service.dart +++ b/lib/services/books/book_download_service.dart @@ -105,7 +105,6 @@ class BookDownloadService { Exception? lastException; final client = HttpClient(); - client.badCertificateCallback = (cert, host, port) => true; for (final urlStr in candidateUrls) { try { diff --git a/lib/services/music/music_download_service.dart b/lib/services/music/music_download_service.dart index acd12d3c..33ca44fa 100644 --- a/lib/services/music/music_download_service.dart +++ b/lib/services/music/music_download_service.dart @@ -366,8 +366,7 @@ class MusicDownloadService extends ChangeNotifier { if (await coverFile.exists() && await coverFile.length() > 100) { localCoverPath = coverFile.path; } else { - final client = HttpClient() - ..badCertificateCallback = (cert, host, port) => true; + final client = HttpClient(); final req = await client.getUrl(Uri.parse(track.coverUrl)); final res = await req.close(); if (res.statusCode == 200) { @@ -389,8 +388,7 @@ class MusicDownloadService extends ChangeNotifier { final tempFile = File(p.join(_tracksDir!.path, '$safeId.$format.tmp')); try { - final client = HttpClient() - ..badCertificateCallback = (cert, host, port) => true; + final client = HttpClient(); final uri = Uri.parse(streamUrl); final request = await client.getUrl(uri); diff --git a/lib/services/subtitles/providers/subtitlecat_provider.dart b/lib/services/subtitles/providers/subtitlecat_provider.dart index 929da7e4..b43721af 100644 --- a/lib/services/subtitles/providers/subtitlecat_provider.dart +++ b/lib/services/subtitles/providers/subtitlecat_provider.dart @@ -12,8 +12,7 @@ class SubtitleCatProvider extends SubtitleProvider { String get name => 'SubtitleCat'; final HttpClient _httpClient = HttpClient() - ..connectionTimeout = const Duration(seconds: 4) - ..badCertificateCallback = ((cert, host, port) => true); + ..connectionTimeout = const Duration(seconds: 4); @override Future> search( diff --git a/lib/services/subtitles/subtitlecat_service.dart b/lib/services/subtitles/subtitlecat_service.dart index 9e30ae88..4e9586f5 100644 --- a/lib/services/subtitles/subtitlecat_service.dart +++ b/lib/services/subtitles/subtitlecat_service.dart @@ -44,8 +44,7 @@ class SubtitleCatService { }; final HttpClient _httpClient = HttpClient() - ..connectionTimeout = const Duration(seconds: 15) - ..badCertificateCallback = ((cert, host, port) => true); + ..connectionTimeout = const Duration(seconds: 15); // ── In-memory caches (bounded FIFO so repeated browsing can't grow RAM) ─── static const int _maxSearchEntries = 80; From 5306c434906ea173d89fef46fe4fd8c89f70b9f9 Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 09:32:03 -0300 Subject: [PATCH 06/19] security: verify update checksums --- .github/workflows/build.yml | 25 ++++++ lib/services/updater/app_updater_service.dart | 76 +++++++++++++++++ lib/widgets/updater/update_dialog.dart | 82 +++++++++++++++++++ .../updater/app_updater_service_test.dart | 47 +++++++++++ 4 files changed, 230 insertions(+) create mode 100644 test/services/updater/app_updater_service_test.dart diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 26574a52..4aee5b22 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -623,6 +623,30 @@ jobs: printf 'This program comes with ABSOLUTELY NO WARRANTY.\n' } > release_notes.md + - name: Generate SHA-256 checksums + shell: bash + run: | + : > SHA256SUMS + for file in \ + ZPlay-Windows-Setup.exe \ + ZPlay-Windows-x64-Portable.zip \ + app-arm64-v8a-release.apk \ + app-armeabi-v7a-release.apk \ + app-x86_64-release.apk \ + ZPlay-Linux-x86_64.AppImage \ + ZPlay-Linux-x86_64.tar.gz \ + ZPlay-macOS-arm64.dmg \ + ZPlay-macOS-arm64.zip \ + ZPlay-macOS-intel.dmg \ + ZPlay-macOS-intel.zip \ + ZPlay-iOS.ipa + do + if [ -f "$file" ]; then + sha256sum "$file" >> SHA256SUMS + fi + done + cat SHA256SUMS + - name: Publish Release with Assets # v3 runs on Node 24. v2 is the final v2 release and pins the Node 20 # runtime that GitHub Actions has deprecated. @@ -663,3 +687,4 @@ jobs: ZPlay-macOS-intel.dmg ZPlay-macOS-intel.zip ZPlay-iOS.ipa + SHA256SUMS diff --git a/lib/services/updater/app_updater_service.dart b/lib/services/updater/app_updater_service.dart index 6166c108..019b31ec 100644 --- a/lib/services/updater/app_updater_service.dart +++ b/lib/services/updater/app_updater_service.dart @@ -73,11 +73,16 @@ class AppUpdaterService { final assets = data['assets'] as List; final downloadUrl = _findAssetForPlatform(assets); + final expectedSha256 = downloadUrl == null + ? null + : await _fetchExpectedChecksum(assets, downloadUrl); return UpdateInfo( currentVersion: currentVersion, latestVersion: latestVersion, downloadUrl: downloadUrl ?? data['html_url'], + releasePageUrl: data['html_url'] as String?, + sha256: expectedSha256, releaseNotes: releaseNotes, publishedAt: publishedAt, isMacOS: kIsWeb ? false : Platform.isMacOS, @@ -92,6 +97,68 @@ class AppUpdaterService { } } + /// Finds the release's checksum manifest. Releases published by the release + /// workflow include `SHA256SUMS`; older releases do not. + String? _findChecksumAsset(List assets) { + for (final asset in assets) { + final name = (asset['name'] as String? ?? '').toLowerCase(); + if (name == 'sha256sums' || + name == 'sha256sums.txt' || + name == 'checksums.txt') { + return asset['browser_download_url'] as String?; + } + } + return null; + } + + Future _fetchExpectedChecksum( + List assets, + String downloadUrl, + ) async { + final checksumUrl = _findChecksumAsset(assets); + final assetName = _assetNameFromUrl(downloadUrl); + if (checksumUrl == null || assetName.isEmpty) return null; + + try { + final response = await http.get( + Uri.parse(checksumUrl), + headers: const {'Accept': 'text/plain'}, + ); + if (response.statusCode != 200) { + debugPrint( + '[AppUpdaterService] Checksum manifest returned ${response.statusCode}', + ); + return null; + } + return checksumForAsset(response.body, assetName); + } catch (e) { + debugPrint('[AppUpdaterService] Could not read checksum manifest: $e'); + return null; + } + } + + static String _assetNameFromUrl(String url) { + final segments = Uri.tryParse(url)?.pathSegments; + if (segments == null || segments.isEmpty) return ''; + return segments.last; + } + + /// Parses one line of the `sha256sum` output format: + /// `<64 hex chars> ` (or `*`). + static String? checksumForAsset(String manifest, String assetName) { + if (assetName.isEmpty) return null; + final linePattern = RegExp(r'^([0-9a-fA-F]{64})\s+\*?(.+?)\s*$'); + for (final line in const LineSplitter().convert(manifest)) { + final match = linePattern.firstMatch(line.trim()); + if (match == null) continue; + final name = match.group(2)!.split('/').last; + if (name == assetName) { + return match.group(1)!.toLowerCase(); + } + } + return null; + } + /// Detects the current CPU architecture and finds the matching release asset. String? _findAssetForPlatform(List assets) { if (kIsWeb) return null; @@ -303,6 +370,13 @@ class UpdateInfo { final String latestVersion; final String downloadUrl; + /// SHA-256 of the selected download asset, published in the release's + /// `SHA256SUMS`. Null when the release does not publish a checksum. + final String? sha256; + + /// The GitHub release page, used when automatic installation is refused. + final String? releasePageUrl; + /// The release body exactly as published on GitHub: Markdown, and for older /// releases the licence block and GitHub's generated commit list as well. final String releaseNotes; @@ -315,6 +389,8 @@ class UpdateInfo { required this.currentVersion, required this.latestVersion, required this.downloadUrl, + this.sha256, + this.releasePageUrl, required this.releaseNotes, required this.publishedAt, required this.isMacOS, diff --git a/lib/widgets/updater/update_dialog.dart b/lib/widgets/updater/update_dialog.dart index 4107139d..0b29c56a 100644 --- a/lib/widgets/updater/update_dialog.dart +++ b/lib/widgets/updater/update_dialog.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; @@ -376,7 +377,69 @@ class _UpdateDialogState extends State { } } + String? get _expectedChecksum { + final value = widget.updateInfo.sha256?.trim().toLowerCase(); + if (value == null || !RegExp(r'^[0-9a-f]{64}$').hasMatch(value)) { + return null; + } + return value; + } + + Future _offerUnverifiedRelease() async { + if (!mounted) return; + final openRelease = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + backgroundColor: context.tokens.surfaceOverlay, + title: Text( + 'Update could not be verified', + style: ZplayType.title.toStyle(color: context.tokens.textPrimary), + ), + content: Text( + 'This release does not publish a SHA-256 checksum, so ZPlay will ' + 'not install it automatically. Open the release page and verify ' + 'the download yourself before installing it.', + style: ZplayType.body.toStyle(color: context.tokens.textEmphasis), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text( + 'Cancel', + style: ZplayType.label.toStyle( + color: context.tokens.textEmphasis, + ), + ), + ), + ElevatedButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: context.tokens.accent, + foregroundColor: context.tokens.onAccent, + ), + child: Text( + 'Open release page', + style: ZplayType.label.toStyle(), + ), + ), + ], + ), + ); + if (openRelease == true) { + await AppUpdaterService().openDownloadPage( + widget.updateInfo.releasePageUrl ?? widget.updateInfo.downloadUrl, + ); + if (mounted) Navigator.of(context).pop(); + } + } + Future _downloadAndInstallAndroid() async { + final checksum = _expectedChecksum; + if (checksum == null) { + await _offerUnverifiedRelease(); + return; + } + WakelockPlus.enable(); setState(() { _isDownloading = true; @@ -389,6 +452,7 @@ class _UpdateDialogState extends State { widget.updateInfo.downloadUrl, destinationFilename: 'ZPlay_${widget.updateInfo.latestVersion}.apk', + sha256checksum: checksum, ) .listen( (OtaEvent event) { @@ -484,6 +548,12 @@ class _UpdateDialogState extends State { } Future _downloadAndInstallDesktop() async { + final checksum = _expectedChecksum; + if (checksum == null) { + await _offerUnverifiedRelease(); + return; + } + WakelockPlus.enable(); setState(() { _isDownloading = true; @@ -539,6 +609,18 @@ class _UpdateDialogState extends State { await sink.close(); final installer = File(filePath); + final actualChecksum = (await sha256.bind(installer.openRead()).first) + .toString() + .toLowerCase(); + if (actualChecksum != checksum) { + try { + await installer.delete(); + } catch (_) {} + throw Exception( + 'The update checksum did not match. The download was deleted.', + ); + } + if (mounted) { WakelockPlus.disable(); setState(() => _isDownloading = false); diff --git a/test/services/updater/app_updater_service_test.dart b/test/services/updater/app_updater_service_test.dart new file mode 100644 index 00000000..9915aae2 --- /dev/null +++ b/test/services/updater/app_updater_service_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zplay/services/updater/app_updater_service.dart'; + +void main() { + const hash = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + + group('AppUpdaterService.checksumForAsset', () { + test('reads sha256sum output', () { + expect( + AppUpdaterService.checksumForAsset( + '$hash app-arm64-v8a-release.apk\n', + 'app-arm64-v8a-release.apk', + ), + hash, + ); + }); + + test('accepts the binary-mode marker and a path prefix', () { + expect( + AppUpdaterService.checksumForAsset( + '$hash *./ZPlay-Windows-Setup.exe', + 'ZPlay-Windows-Setup.exe', + ), + hash, + ); + }); + + test('does not match another asset', () { + expect( + AppUpdaterService.checksumForAsset('$hash other.apk', 'app.apk'), + isNull, + ); + }); + + test('ignores malformed lines', () { + expect( + AppUpdaterService.checksumForAsset('not-a-hash app.apk', 'app.apk'), + isNull, + ); + }); + + test('returns null for an empty asset name', () { + expect(AppUpdaterService.checksumForAsset('$hash app.apk', ''), isNull); + }); + }); +} From 61d5f082021d5c7bb0ac2a5f80d96d9217edee4b Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 09:36:19 -0300 Subject: [PATCH 07/19] security: redact credentials from logs --- lib/pages/player/player_screen.dart | 22 +++++----- lib/services/anime_arabic/mega_proxy.dart | 4 +- .../cloudstream/cloudstream_manager.dart | 9 ++-- lib/services/iptv/pastesh_decryptor.dart | 4 +- .../iptv/sources/m3u_remote_source.dart | 5 ++- lib/services/scraper/sites/hindmoviez.dart | 5 ++- lib/utils/security/log_redaction.dart | 21 ++++++++++ test/utils/security/log_redaction_test.dart | 41 +++++++++++++++++++ 8 files changed, 91 insertions(+), 20 deletions(-) create mode 100644 lib/utils/security/log_redaction.dart create mode 100644 test/utils/security/log_redaction_test.dart diff --git a/lib/pages/player/player_screen.dart b/lib/pages/player/player_screen.dart index ab31d47c..76bcd671 100644 --- a/lib/pages/player/player_screen.dart +++ b/lib/pages/player/player_screen.dart @@ -62,6 +62,7 @@ import '../../widgets/player/text_sync_overlay.dart'; import '../../models/download/download_task_model.dart'; import '../../services/download/download_service.dart'; import '../../utils/download/download_path_helper.dart'; +import '../../utils/security/log_redaction.dart'; class _PlayerTogglePlayPauseIntent extends Intent { const _PlayerTogglePlayPauseIntent(); @@ -1083,7 +1084,7 @@ class _PlayerScreenState extends State { print('[PlayerScreen] Source Name: ${_currentSource.name}'); print('[PlayerScreen] Addon Name: ${_currentSource.addonName}'); print('[PlayerScreen] Source Title: ${_currentSource.title}'); - print('[PlayerScreen] Raw URL: ${_currentSource.url}'); + print('[PlayerScreen] Raw URL: ${redactUrl(_currentSource.url)}'); try { final rawUrl = _currentSource.url; @@ -1151,7 +1152,9 @@ class _PlayerScreenState extends State { } streamUrl = debridFiles.first.downloadUrl; - print('[PlayerScreen] Debrid resolved stream URL: $streamUrl'); + print( + '[PlayerScreen] Debrid resolved stream URL: ${redactUrl(streamUrl)}', + ); } else { if (!mounted) return; setState(() => _statusMessage = 'Gathering metadata & peers...'); @@ -1189,10 +1192,9 @@ class _PlayerScreenState extends State { playerHeaders.addAll(Map.from(proxyReqHeaders)); } - final cleanUri = Uri.parse(sanitizedUrlStr); _activeStreamUrl = sanitizedUrlStr; print( - '[PlayerScreen] Opening direct network stream URL: $cleanUri (headers: ${playerHeaders.keys})', + '[PlayerScreen] Opening direct network stream URL: ${redactUrl(sanitizedUrlStr)} (headers: ${playerHeaders.keys})', ); if (!mounted) return; @@ -1233,7 +1235,7 @@ class _PlayerScreenState extends State { CrashBreadcrumbs.stream('open.start', title: _currentTitle); final bool usedMedia3 = await _openSource( ve.EngineRequest( - url: cleanUri.toString(), + url: sanitizedUrlStr, headers: isTorrentStream ? const {} : playerHeaders, start: seekTarget ?? Duration.zero, isLive: isLive, @@ -1258,7 +1260,7 @@ class _PlayerScreenState extends State { } print( - '[PlayerScreen SUCCESS] Player opened media successfully for $streamUrl', + '[PlayerScreen SUCCESS] Player opened media successfully for ${redactUrl(streamUrl)}', ); if (!usedMedia3) _updateMediaTracks(_player.state.tracks); @@ -1344,7 +1346,7 @@ class _PlayerScreenState extends State { context: 'PlayerScreen.initStream $_currentTitle', ); print( - '[PlayerScreen ERROR] Failed to initialize stream URL: "$streamUrl"', + '[PlayerScreen ERROR] Failed to initialize stream URL: "${redactUrl(streamUrl)}"', ); print('[PlayerScreen ERROR] Exception: $e'); print('[PlayerScreen ERROR] StackTrace:\n$stackTrace'); @@ -1391,10 +1393,10 @@ class _PlayerScreenState extends State { // in the breadcrumb context, which is where the detail belongs. CrashBreadcrumbs.error( e, - context: '${e.service} ${e.kind.name} ${e.detail}', + context: '${e.service} ${e.kind.name} ${redactTextUrls(e.detail)}', ); print( - '[PlayerScreen] Debrid failure (${e.kind.name}) from ${e.service}: ${e.detail}', + '[PlayerScreen] Debrid failure (${e.kind.name}) from ${e.service}: ${redactTextUrls(e.detail)}', ); if (!mounted) return; @@ -2797,7 +2799,7 @@ class _PlayerScreenState extends State { context: 'PlayerScreen.playback $_currentTitle', ); print( - '[PlayerScreen ERROR] Critical player error on dead stream: $errorMsg', + '[PlayerScreen ERROR] Critical player error on dead stream: ${redactTextUrls(errorMsg)}', ); _reportPlaybackFailure('Playback error: $errorMsg'); diff --git a/lib/services/anime_arabic/mega_proxy.dart b/lib/services/anime_arabic/mega_proxy.dart index 2efc9200..1ba51e49 100644 --- a/lib/services/anime_arabic/mega_proxy.dart +++ b/lib/services/anime_arabic/mega_proxy.dart @@ -5,6 +5,8 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:pointycastle/export.dart'; +import '../../utils/security/log_redaction.dart'; + class MegaResolved { final String url; final int size; @@ -50,7 +52,7 @@ class MegaProxy { try { final parsed = _parseEmbed(embedUrl); if (parsed == null) { - debugPrint('[MegaProxy] could not parse embed: $embedUrl'); + debugPrint('[MegaProxy] could not parse embed: ${redactUrl(embedUrl)}'); return null; } final (fileId, keyBytes) = parsed; diff --git a/lib/services/cloudstream/cloudstream_manager.dart b/lib/services/cloudstream/cloudstream_manager.dart index 214a227f..482504bf 100644 --- a/lib/services/cloudstream/cloudstream_manager.dart +++ b/lib/services/cloudstream/cloudstream_manager.dart @@ -13,6 +13,7 @@ import '../../models/cloudstream/cloudstream_source.dart'; import '../../models/stream/stream_model.dart'; import '../../models/movie/movie_detail.dart'; import '../../models/movie/video.dart'; +import '../../utils/security/log_redaction.dart'; import '../../utils/security/safe_paths.dart'; import 'runtime/cloudstream_downloader.dart'; import 'runtime/cloudstream_paths.dart'; @@ -1140,7 +1141,7 @@ class CloudStreamManager { if (epNum == episode && seasonMatches) { targetEpisodeDataUrl = _extractEpisodeStreamUrl(ep); - debugPrint('[CloudStreamManager] [$providerName] Matched S${sNum ?? season ?? 1}:E$epNum ("${ep['name'] ?? 'Episode $epNum'}") -> $targetEpisodeDataUrl'); + debugPrint('[CloudStreamManager] [$providerName] Matched S${sNum ?? season ?? 1}:E$epNum ("${ep['name'] ?? 'Episode $epNum'}") -> ${redactUrl(targetEpisodeDataUrl)}'); break; } } @@ -1164,7 +1165,7 @@ class CloudStreamManager { } if (sessionId != null && sessionId != _scrapeSessionId) return; - debugPrint('[CloudStreamManager] [$providerName] Resolving video streams for "$targetEpisodeDataUrl"...'); + debugPrint('[CloudStreamManager] [$providerName] Resolving video streams for "${redactUrl(targetEpisodeDataUrl)}"...'); // 3. Stream links await for (final linkData in CloudStreamDispatcher.instance.getVideoListStream( @@ -1375,7 +1376,7 @@ class CloudStreamManager { String? fallbackPoster, }) async { try { - debugPrint('[CloudStreamManager] fetchCloudStreamDetail: sourceId=$sourceId, url=$mediaUrl'); + debugPrint('[CloudStreamManager] fetchCloudStreamDetail: sourceId=$sourceId, url=${redactUrl(mediaUrl)}'); final res = await CloudStreamDispatcher.instance.getDetail( sourceId: sourceId, url: mediaUrl, @@ -1487,7 +1488,7 @@ class CloudStreamManager { String? providerName, String? title, }) async* { - debugPrint('[CloudStreamManager] resolveStreamsForDataUrl: sourceId=$sourceId, url=$dataUrl'); + debugPrint('[CloudStreamManager] resolveStreamsForDataUrl: sourceId=$sourceId, url=${redactUrl(dataUrl)}'); final extName = providerName ?? sourceId.replaceFirst('cs_', ''); try { await for (final linkData in CloudStreamDispatcher.instance.getVideoListStream( diff --git a/lib/services/iptv/pastesh_decryptor.dart b/lib/services/iptv/pastesh_decryptor.dart index 29e35666..aee4291b 100644 --- a/lib/services/iptv/pastesh_decryptor.dart +++ b/lib/services/iptv/pastesh_decryptor.dart @@ -12,6 +12,8 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:pointycastle/export.dart' as pc; +import '../../../utils/security/log_redaction.dart'; + class PasteShDecryptor { static const _ua = 'Mozilla/5.0 (Linux; Android 11) AppleWebKit/537.36 (KHTML, like Gecko)'; @@ -120,7 +122,7 @@ class PasteShDecryptor { if (resp.statusCode < 200 || resp.statusCode >= 300) return null; return resp.body; } catch (e) { - debugPrint('Decrypt GET failed: $e'); + debugPrint('Decrypt GET failed: ${redactTextUrls('$e')}'); return null; } } diff --git a/lib/services/iptv/sources/m3u_remote_source.dart b/lib/services/iptv/sources/m3u_remote_source.dart index 8afa6a7b..a836536f 100644 --- a/lib/services/iptv/sources/m3u_remote_source.dart +++ b/lib/services/iptv/sources/m3u_remote_source.dart @@ -22,6 +22,7 @@ import 'package:http/http.dart' as http; import '../m3u_parser.dart'; import '../m3u_store.dart'; +import '../../../utils/security/log_redaction.dart'; import 'external_channel_source.dart'; /// How [RemoteM3uSource] reads the playlist body. Injectable so the parsing, @@ -95,7 +96,7 @@ class RemoteM3uSource implements ExternalChannelSource { } catch (e) { // Nothing is cached on the way out: a host that was down for a minute // must not take this source out for the whole TTL. - debugPrint('RemoteM3uSource "$label" failed: $e'); + debugPrint('RemoteM3uSource "$label" failed: ${redactTextUrls('$e')}'); return const []; } } @@ -201,7 +202,7 @@ class SavedM3uSource implements ExternalChannelSource { _cachedAt = DateTime.now(); return out; } catch (e) { - debugPrint('SavedM3uSource failed: $e'); + debugPrint('SavedM3uSource failed: ${redactTextUrls('$e')}'); return const []; } } diff --git a/lib/services/scraper/sites/hindmoviez.dart b/lib/services/scraper/sites/hindmoviez.dart index aa6c8c50..95bc53c5 100644 --- a/lib/services/scraper/sites/hindmoviez.dart +++ b/lib/services/scraper/sites/hindmoviez.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../stream_scraper.dart'; import '../../../models/stream/stream_model.dart'; +import '../../../utils/security/log_redaction.dart'; /// HindMoviez Stream Scraper for ZPlayHTTP. /// @@ -678,7 +679,7 @@ class HindMoviezScraper extends StreamScraper { final cookieHeader = chalRes.headers['set-cookie']; final locationHeader = chalRes.headers['location']; - debugPrint('[HindMoviez] Challenge response: status=${chalRes.statusCode}, location=$locationHeader, set-cookie=$cookieHeader'); + debugPrint('[HindMoviez] Challenge response: status=${chalRes.statusCode}, location=${redactUrl(locationHeader)}, set-cookie=${cookieHeader != null}'); String? cookie; if (cookieHeader != null) { @@ -691,7 +692,7 @@ class HindMoviezScraper extends StreamScraper { ? (locationHeader.startsWith('http') ? locationHeader : 'https://hshare.ink$locationHeader') : rUrl; - debugPrint('[HindMoviez] Fetching unlocked target with redirects: $finalTarget (cookie: $cookie)'); + debugPrint('[HindMoviez] Fetching unlocked target with redirects: ${redactUrl(finalTarget)} (cookie present: ${cookie != null})'); final finalRes = await _getWithCookieAndRedirects( url: finalTarget, referer: rUrl, diff --git a/lib/utils/security/log_redaction.dart b/lib/utils/security/log_redaction.dart new file mode 100644 index 00000000..50413b34 --- /dev/null +++ b/lib/utils/security/log_redaction.dart @@ -0,0 +1,21 @@ +/// Reduces a URL to scheme, host, and port before it reaches a log. +/// +/// Stream URLs frequently carry credentials in the userinfo, path, or query +/// (Xtream, debrid links, signed CDN tokens), so none of those parts are safe. +String redactUrl(String? value) { + if (value == null || value.trim().isEmpty) return ''; + final uri = Uri.tryParse(value.trim()); + if (uri == null || !uri.hasScheme || uri.host.isEmpty) { + return ''; + } + final port = uri.hasPort ? ':${uri.port}' : ''; + return '${uri.scheme}://${uri.host}$port'; +} + +/// Redacts every HTTP(S) URL embedded in free-form diagnostic text. +String redactTextUrls(String text) { + return text.replaceAllMapped( + RegExp(r'''https?://[^\s<>"']+'''), + (match) => redactUrl(match.group(0)), + ); +} diff --git a/test/utils/security/log_redaction_test.dart b/test/utils/security/log_redaction_test.dart new file mode 100644 index 00000000..29f3661d --- /dev/null +++ b/test/utils/security/log_redaction_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zplay/utils/security/log_redaction.dart'; + +void main() { + group('redactUrl', () { + test('drops userinfo, path, query, and fragment', () { + expect( + redactUrl( + 'https://user:pass@example.com:8443/live/user/pass/1.ts?token=secret#t', + ), + 'https://example.com:8443', + ); + }); + + test('keeps the default port implicit', () { + expect(redactUrl('https://example.com/path'), 'https://example.com'); + }); + + test('returns a placeholder for non-URLs', () { + expect(redactUrl('not a url'), ''); + expect(redactUrl(null), ''); + expect(redactUrl(''), ''); + }); + }); + + group('redactTextUrls', () { + test('redacts every URL in a diagnostic message', () { + expect( + redactTextUrls( + 'GET https://user:pass@one.example/live/a.ts failed; ' + 'retry http://two.example:8080/x?key=secret', + ), + 'GET https://one.example failed; retry http://two.example:8080', + ); + }); + + test('leaves text without URLs alone', () { + expect(redactTextUrls('timeout after 10s'), 'timeout after 10s'); + }); + }); +} From 9317dde9001b0931a50340a0fec7318da9d0d316 Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 09:37:42 -0300 Subject: [PATCH 08/19] security: harden mega proxy token and CORS --- lib/services/anime_arabic/mega_proxy.dart | 54 ++++++++++++++++--- .../anime_arabic/mega_proxy_test.dart | 19 +++++++ test/utils/security/safe_paths_test.dart | 10 ++++ 3 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 test/services/anime_arabic/mega_proxy_test.dart diff --git a/lib/services/anime_arabic/mega_proxy.dart b/lib/services/anime_arabic/mega_proxy.dart index 1ba51e49..f140c57e 100644 --- a/lib/services/anime_arabic/mega_proxy.dart +++ b/lib/services/anime_arabic/mega_proxy.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:pointycastle/export.dart'; import '../../utils/security/log_redaction.dart'; +import '../../utils/security/safe_paths.dart'; class MegaResolved { final String url; @@ -36,6 +37,20 @@ class MegaProxy { final Map _files = {}; int _seq = 0; + static const int _maxFiles = 32; + static final RegExp _tokenPath = RegExp(r'^/v/([0-9a-f]{64})\.mp4$'); + + /// Browser origins allowed to read the loopback proxy. A native player sends + /// no Origin and needs no CORS grant. + @visibleForTesting + static bool isAllowedOrigin(String origin) { + final uri = Uri.tryParse(origin); + if (uri == null || uri.host.isEmpty) return false; + return uri.host == '127.0.0.1' || + uri.host == 'localhost' || + uri.host == '::1'; + } + Future _ensureServer() async { if (_server != null) return _server!.port; final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); @@ -74,12 +89,15 @@ class MegaProxy { final size = (api['s'] as num?)?.toInt() ?? 0; final dlUrl = api['g']?.toString() ?? ''; if (size <= 0 || dlUrl.isEmpty) { - debugPrint('[MegaProxy] api missing g/s: $api'); + debugPrint('[MegaProxy] api response missing g/s'); return null; } final port = await _ensureServer(); - final token = '${DateTime.now().microsecondsSinceEpoch}_${_seq++}'; + final token = secureRandomHex(32); + if (_files.length >= _maxFiles) { + _files.remove(_files.keys.first); + } _files[token] = _MegaFile( dlUrl: dlUrl, size: size, @@ -89,7 +107,9 @@ class MegaProxy { final url = 'http://127.0.0.1:$port/v/$token.mp4'; return MegaResolved(url: url, size: size); } catch (e, st) { - debugPrint('[MegaProxy] resolve failed: $e\n$st'); + debugPrint( + '[MegaProxy] resolve failed: ${redactTextUrls('$e')}\n${redactTextUrls('$st')}', + ); return null; } } @@ -124,13 +144,20 @@ class MegaProxy { // Loopback HTTP server handler // ──────────────────────────────────────────────────────────────────── Future _handle(HttpRequest req) async { - final path = req.uri.path; - if (!path.startsWith('/v/')) { + final origin = req.headers.value('origin'); + if (origin != null && !isAllowedOrigin(origin)) { + req.response.statusCode = HttpStatus.forbidden; + await req.response.close(); + return; + } + + final match = _tokenPath.firstMatch(req.uri.path); + if (match == null) { req.response.statusCode = HttpStatus.notFound; await req.response.close(); return; } - final token = path.substring(3).replaceAll('.mp4', ''); + final token = match.group(1)!; final file = _files[token]; if (file == null) { req.response.statusCode = HttpStatus.notFound; @@ -138,8 +165,19 @@ class MegaProxy { return; } - // CORS & range support - req.response.headers.set('Access-Control-Allow-Origin', '*'); + if (origin != null) { + req.response.headers.set('Access-Control-Allow-Origin', origin); + req.response.headers.set('Vary', 'Origin'); + req.response.headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS'); + req.response.headers.set('Access-Control-Allow-Headers', 'Range'); + } + if (req.method == 'OPTIONS') { + req.response.statusCode = HttpStatus.noContent; + await req.response.close(); + return; + } + + // Range support req.response.headers.set('Accept-Ranges', 'bytes'); req.response.headers.set('Content-Type', 'video/mp4'); diff --git a/test/services/anime_arabic/mega_proxy_test.dart b/test/services/anime_arabic/mega_proxy_test.dart new file mode 100644 index 00000000..c15a3a50 --- /dev/null +++ b/test/services/anime_arabic/mega_proxy_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zplay/services/anime_arabic/mega_proxy.dart'; + +void main() { + group('MegaProxy.isAllowedOrigin', () { + test('allows loopback origins on any port', () { + expect(MegaProxy.isAllowedOrigin('http://127.0.0.1:8080'), isTrue); + expect(MegaProxy.isAllowedOrigin('http://localhost:3000'), isTrue); + expect(MegaProxy.isAllowedOrigin('http://[::1]:9090'), isTrue); + }); + + test('rejects internet, null, and malformed origins', () { + expect(MegaProxy.isAllowedOrigin('https://evil.example'), isFalse); + expect(MegaProxy.isAllowedOrigin('null'), isFalse); + expect(MegaProxy.isAllowedOrigin('not an origin'), isFalse); + expect(MegaProxy.isAllowedOrigin('file://'), isFalse); + }); + }); +} diff --git a/test/utils/security/safe_paths_test.dart b/test/utils/security/safe_paths_test.dart index 5906e573..359ad044 100644 --- a/test/utils/security/safe_paths_test.dart +++ b/test/utils/security/safe_paths_test.dart @@ -54,6 +54,16 @@ void main() { }); }); + group('secureRandomHex', () { + test('returns the requested number of lowercase hex bytes', () { + expect(RegExp(r'^[0-9a-f]{32}$').hasMatch(secureRandomHex(16)), isTrue); + }); + + test('does not repeat', () { + expect(secureRandomHex(32), isNot(secureRandomHex(32))); + }); + }); + group('sanitizeFileComponent', () { test('removes path separators and reserved characters', () { final sanitized = sanitizeFileComponent(r'..\evil/name?.jar'); From 796d2b27420d93b2bf6c15162aaf976494c568c9 Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 09:38:36 -0300 Subject: [PATCH 09/19] perf: throttle reader and player progress writes --- lib/pages/audiobooks/audiobook_player_screen.dart | 4 ++-- lib/pages/books/epub_reader_page.dart | 7 ++++++- lib/pages/player/player_screen.dart | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/pages/audiobooks/audiobook_player_screen.dart b/lib/pages/audiobooks/audiobook_player_screen.dart index fcc6e7bc..05fec0ab 100644 --- a/lib/pages/audiobooks/audiobook_player_screen.dart +++ b/lib/pages/audiobooks/audiobook_player_screen.dart @@ -95,8 +95,8 @@ class _AudiobookPlayerScreenState extends State _initChapter(_currentChapterIndex); - // Save progress every 5 seconds - _progressTimer = Timer.periodic(const Duration(seconds: 5), (_) { + // Save progress periodically; pause, seek, and dispose also flush it. + _progressTimer = Timer.periodic(const Duration(seconds: 30), (_) { _saveProgress(); }); } diff --git a/lib/pages/books/epub_reader_page.dart b/lib/pages/books/epub_reader_page.dart index bbfd24f6..8251d9f8 100644 --- a/lib/pages/books/epub_reader_page.dart +++ b/lib/pages/books/epub_reader_page.dart @@ -48,6 +48,7 @@ class _EpubReaderPageState extends State { bool _showChrome = false; // Zero chrome by default bool _isHoveringControls = false; Timer? _chromeTimer; + Timer? _progressSaveTimer; @override void initState() { @@ -60,6 +61,8 @@ class _EpubReaderPageState extends State { @override void dispose() { _chromeTimer?.cancel(); + _progressSaveTimer?.cancel(); + _saveProgress(); _scrollController.removeListener(_onScrollChanged); _scrollController.dispose(); _readerFocusNode.dispose(); @@ -68,7 +71,8 @@ class _EpubReaderPageState extends State { } void _onScrollChanged() { - _saveProgress(); + _progressSaveTimer?.cancel(); + _progressSaveTimer = Timer(const Duration(seconds: 2), _saveProgress); } Future _loadBook() async { @@ -165,6 +169,7 @@ class _EpubReaderPageState extends State { _scrollController.jumpTo(0); } + _progressSaveTimer?.cancel(); _saveProgress(); } diff --git a/lib/pages/player/player_screen.dart b/lib/pages/player/player_screen.dart index 76bcd671..d573e8ba 100644 --- a/lib/pages/player/player_screen.dart +++ b/lib/pages/player/player_screen.dart @@ -1324,7 +1324,7 @@ class _PlayerScreenState extends State { }); _progressSaveTimer?.cancel(); - _progressSaveTimer = Timer.periodic(const Duration(seconds: 5), (_) { + _progressSaveTimer = Timer.periodic(const Duration(seconds: 30), (_) { _savePlaybackProgress(); }); } catch (e, stackTrace) { From 259b5b331496e07cf7c173da2749f5281ef40e99 Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 09:40:02 -0300 Subject: [PATCH 10/19] perf: stop hidden home refresh and duplicate update check --- lib/pages/home/home_page.dart | 37 ++++++++++------------------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index 788f3217..5e392220 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -49,8 +49,6 @@ import '../ai/wewatch_quiz_page.dart'; import '../calendar/tv_calendar_page.dart'; import '../../shell/app_shell_scope.dart'; import '../../services/theme/design_tokens.dart'; -import '../../services/updater/app_updater_service.dart'; -import '../../widgets/updater/update_dialog.dart'; import '../../services/p2p/p2p_settings_service.dart'; import '../../widgets/p2p/p2p_warning_dialog.dart'; import 'package:flutter/services.dart'; @@ -429,6 +427,7 @@ class _HomePageState extends State { /// switch only adds or drops them from the rail list. void _onCollectionsChanged() { if (!mounted) return; + if (!_isHomeVisible) return; _injectCuratedSections(); } @@ -492,7 +491,11 @@ class _HomePageState extends State { } static bool _hasRunStartupDialogs = false; - static bool _hasAutoCheckedUpdate = false; + + bool get _isHomeVisible { + final controller = _shellController; + return controller == null || controller.current.value == ShellSlot.home; + } @override void didChangeDependencies() { @@ -517,7 +520,9 @@ class _HomePageState extends State { final slot = _shellController?.current.value; final returnedHome = slot == ShellSlot.home && _lastSlot != ShellSlot.home; _lastSlot = slot; - if (returnedHome && mounted) _loadHome(); + if (returnedHome && mounted) { + _loadHome(); + } } /// Vertical space one content rail may use on Home, chrome above it excluded. @@ -579,32 +584,9 @@ class _HomePageState extends State { if (_hasRunStartupDialogs || !mounted) return; _hasRunStartupDialogs = true; - // 1. Check & show Update dialog first - await _checkAutoUpdate(); - if (!mounted) return; - - // 2. Check & show P2P warning dialog after update dialog await _checkP2pWarning(); } - Future _checkAutoUpdate() async { - if (_hasAutoCheckedUpdate) return; - _hasAutoCheckedUpdate = true; - try { - final updater = AppUpdaterService(); - final updateInfo = await updater.checkForUpdates(); - if (updateInfo != null && mounted) { - await showDialog( - context: context, - barrierDismissible: true, - builder: (context) => UpdateDialog(updateInfo: updateInfo), - ); - } - } catch (e) { - debugPrint('[HomePage] Auto update check failed: $e'); - } - } - Future _checkP2pWarning() async { try { final shouldShow = await P2pSettingsService.shouldShowWarning(); @@ -645,6 +627,7 @@ class _HomePageState extends State { void _onSettingsChanged() { if (!mounted) return; + if (!_isHomeVisible) return; // Coalesce rapid my-list / continue-watching / palette ticks into one // trailing refresh; the immediate setState below keeps visuals instant. _similarDebounce?.cancel(); From 8b7cd154ccb5cf4376e141d421fdc2a92d6792b7 Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 09:42:11 -0300 Subject: [PATCH 11/19] perf: bound manga precache and progress writes --- lib/pages/manga/manga_reader_page.dart | 52 +++++++++++++++++--------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/lib/pages/manga/manga_reader_page.dart b/lib/pages/manga/manga_reader_page.dart index 2d05f82c..47cd258e 100644 --- a/lib/pages/manga/manga_reader_page.dart +++ b/lib/pages/manga/manga_reader_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; @@ -52,6 +53,7 @@ class _MangaReaderPageState extends State { PageController? _pageController; final ScrollController _verticalScrollController = ScrollController(); final ScrollController _deckScrollController = ScrollController(); + Timer? _progressSaveTimer; final TransformationController _transformationController = TransformationController(); double _currentZoom = 1.0; @@ -87,6 +89,8 @@ class _MangaReaderPageState extends State { @override void dispose() { + _progressSaveTimer?.cancel(); + _saveProgress(); MangaSettings.changeNotifier.removeListener(_onSettingsChanged); AppThemeService.currentPalette.removeListener(_onSettingsChanged); _focusNode.dispose(); @@ -162,22 +166,31 @@ class _MangaReaderPageState extends State { } }); - // Keep the chapter warm without decoding every page full-res: the deck - // needs all thumbs anyway, so precache those (memCacheWidth 140 like the - // deck). The display widgets below bound pages to 2x screen width, so - // precache current + adjacent with that same bound — same ResizeImage - // key, no second decode. + // Keep a bounded window warm instead of decoding the whole chapter. The + // deck is a lazy list, so far-off thumbnails do not need to be fetched + // until they are actually scrolled into view. final pageCacheWidth = (MediaQuery.sizeOf(context).width * 2).round().clamp(96, 2048).toInt(); - for (final url in urls) { - precacheImage( - ResizeImage.resizeIfNeeded( - 140, - null, - CachedNetworkImageProvider(url, cacheManager: AppImageCache.manager), - ), - context, - ); + if (urls.isNotEmpty) { + final thumbStart = _currentPageIndex - 10 < 0 + ? 0 + : _currentPageIndex - 10; + final thumbEnd = _currentPageIndex + 10 >= urls.length + ? urls.length - 1 + : _currentPageIndex + 10; + for (var i = thumbStart; i <= thumbEnd; i++) { + precacheImage( + ResizeImage.resizeIfNeeded( + 140, + null, + CachedNetworkImageProvider( + urls[i], + cacheManager: AppImageCache.manager, + ), + ), + context, + ); + } } for (var i = _currentPageIndex - 2; i <= _currentPageIndex + 2; i++) { if (i < 0 || i >= urls.length) continue; @@ -211,6 +224,11 @@ class _MangaReaderPageState extends State { ); } + void _scheduleSaveProgress() { + _progressSaveTimer?.cancel(); + _progressSaveTimer = Timer(const Duration(seconds: 2), _saveProgress); + } + void _scrollToCurrentPageInDeck() { if (!_showDeckDrawer || !_deckScrollController.hasClients || _pageUrls.isEmpty) return; final screenW = MediaQuery.sizeOf(context).width; @@ -233,14 +251,14 @@ class _MangaReaderPageState extends State { if (estimatedIndex != _currentPageIndex) { setState(() => _currentPageIndex = estimatedIndex); - _saveProgress(); + _scheduleSaveProgress(); _scrollToCurrentPageInDeck(); } } void _onPageChanged(int index) { setState(() => _currentPageIndex = index); - _saveProgress(); + _scheduleSaveProgress(); _scrollToCurrentPageInDeck(); } @@ -262,7 +280,7 @@ class _MangaReaderPageState extends State { _pageController!.jumpToPage(pageIndex); } } - _saveProgress(); + _scheduleSaveProgress(); _scrollToCurrentPageInDeck(); } From 15277b39b44fc7e1f1f4b8f59d5d742811b5ed8a Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 09:43:28 -0300 Subject: [PATCH 12/19] perf: debounce IPTV portal search --- lib/pages/iptv/iptv_portal_browser_page.dart | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/pages/iptv/iptv_portal_browser_page.dart b/lib/pages/iptv/iptv_portal_browser_page.dart index 30fdc0d2..6c52e553 100644 --- a/lib/pages/iptv/iptv_portal_browser_page.dart +++ b/lib/pages/iptv/iptv_portal_browser_page.dart @@ -51,6 +51,7 @@ class _IptvPortalBrowserPageState extends State { List _allStreams = []; final TextEditingController _searchCtrl = TextEditingController(); + Timer? _searchDebounce; final TextEditingController _catSearchCtrl = TextEditingController(); String _searchQuery = ''; String _catSearchQuery = ''; @@ -131,6 +132,7 @@ class _IptvPortalBrowserPageState extends State { @override void dispose() { _cancelAlive = true; + _searchDebounce?.cancel(); IptvSettings.changeNotifier.removeListener(_onSettingsChanged); AppThemeService.currentPalette.removeListener(_onSettingsChanged); _categoryScrollController.removeListener(_updateCategoryScrollState); @@ -349,9 +351,17 @@ class _IptvPortalBrowserPageState extends State { return found.name; } + void _onSearchChanged(String value) { + _searchDebounce?.cancel(); + _searchDebounce = Timer(const Duration(milliseconds: 180), () { + if (mounted) setState(() => _searchQuery = value); + }); + } + Future _loadSectionData() async { if (widget.portal == null && widget.m3uPlaylist == null) return; + _searchDebounce?.cancel(); setState(() { _isLoading = true; _errorMessage = null; @@ -916,6 +926,7 @@ class _IptvPortalBrowserPageState extends State { ? IconButton( icon: Icon(Icons.close_rounded, color: tokens.textMuted, size: 16), onPressed: () { + _searchDebounce?.cancel(); _searchCtrl.clear(); setState(() => _searchQuery = ''); }, @@ -929,7 +940,7 @@ class _IptvPortalBrowserPageState extends State { borderSide: BorderSide.none, ), ), - onChanged: (v) => setState(() => _searchQuery = v), + onChanged: _onSearchChanged, ), ), @@ -1105,6 +1116,7 @@ class _IptvPortalBrowserPageState extends State { ? IconButton( icon: Icon(Icons.close_rounded, color: tokens.textMuted, size: 16), onPressed: () { + _searchDebounce?.cancel(); _searchCtrl.clear(); setState(() => _searchQuery = ''); }, @@ -1121,7 +1133,7 @@ class _IptvPortalBrowserPageState extends State { horizontal: ZplaySpacing.s8, ), ), - onChanged: (v) => setState(() => _searchQuery = v), + onChanged: _onSearchChanged, ), ), ], From 96d7c187edbfcd7009849b0017639a2a249d0413 Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 09:44:47 -0300 Subject: [PATCH 13/19] perf: decode blurred music backdrop at thumbnail size --- lib/pages/music/music_page.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/pages/music/music_page.dart b/lib/pages/music/music_page.dart index a165408c..b96b87fd 100644 --- a/lib/pages/music/music_page.dart +++ b/lib/pages/music/music_page.dart @@ -4193,6 +4193,8 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT child: CachedNetworkImage( imageUrl: track.coverUrl, cacheManager: AppImageCache.manager, + memCacheWidth: 256, + memCacheHeight: 256, fit: BoxFit.cover, errorWidget: (_, __, ___) => const SizedBox.shrink()), ), From 0d1561b3c85f44c3f9ed994068dd816ab570e94a Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 09:45:25 -0300 Subject: [PATCH 14/19] perf: cache player art and isolate video repaints --- lib/pages/home/home_page.dart | 14 +++++--- lib/pages/player/player_screen.dart | 32 +++++++++++-------- .../player/player_loading_overlay.dart | 16 ++++++---- 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index 5e392220..88541e01 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -714,10 +714,12 @@ class _HomePageState extends State { _traktPublicSections ..clear() ..addAll(fetched.whereType()); - _traktPublicLoaded = true; }); + } catch (e) { + debugPrint('[HomePage] Trakt public sections failed: $e'); } finally { _traktPublicInFlight = false; + _traktPublicLoaded = true; } } @@ -888,9 +890,13 @@ class _HomePageState extends State { // long as it took - which is the one thing the rails are not worth. The // future injects itself when it answers, and a null injects nothing. unawaited( - traktPublic.then((_) { - if (mounted) _injectTraktPublicSections(); - }), + traktPublic + .then((_) { + if (mounted) _injectTraktPublicSections(); + }) + .catchError((Object error) { + debugPrint('[HomePage] Trakt public sections failed: $error'); + }), ); } catch (e) { if (!mounted) return; diff --git a/lib/pages/player/player_screen.dart b/lib/pages/player/player_screen.dart index d573e8ba..8344920f 100644 --- a/lib/pages/player/player_screen.dart +++ b/lib/pages/player/player_screen.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'dart:async'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -30,6 +31,7 @@ import '../../services/player/player_settings.dart'; import '../../services/player/video_panel_metrics.dart'; import '../../services/player/engine/engine_selector.dart'; import '../../services/player/engine/exo_player_engine.dart'; +import '../../services/storage/app_image_cache.dart'; // The torrent engine has an unrelated `EngineState` of its own, and both arrive // through unprefixed imports, so the playback one is under a prefix rather than // the other one being renamed. @@ -3872,14 +3874,16 @@ class _PlayerScreenState extends State { builder: (context, _, __) { final ExoPlayerEngine? exo = _exoEngine; if (exo != null) { - return exo.buildVideoSurface(); + return RepaintBoundary(child: exo.buildVideoSurface()); } - return mk.Video( - controller: _videoController, - fit: _videoFit, - controls: mk.NoVideoControls, - subtitleViewConfiguration: - PlayerSettings.getSubtitleViewConfiguration(), + return RepaintBoundary( + child: mk.Video( + controller: _videoController, + fit: _videoFit, + controls: mk.NoVideoControls, + subtitleViewConfiguration: + PlayerSettings.getSubtitleViewConfiguration(), + ), ); }, ), @@ -3992,9 +3996,10 @@ class _PlayerScreenState extends State { if (showBackdrop) Opacity( opacity: 0.22, - child: Image.network( - backdropUrl, - cacheWidth: 1280, + child: CachedNetworkImage( + imageUrl: backdropUrl, + cacheManager: AppImageCache.manager, + memCacheWidth: 1280, fit: BoxFit.cover, ), ), @@ -4022,9 +4027,10 @@ class _PlayerScreenState extends State { // Same art and the same size as the loading overlay's, // so swapping between the two does not jump. if (logoUrl != null && logoUrl.isNotEmpty) - Image.network( - logoUrl, - cacheHeight: 300, + CachedNetworkImage( + imageUrl: logoUrl, + cacheManager: AppImageCache.manager, + memCacheHeight: 300, height: 100, fit: BoxFit.contain, ) diff --git a/lib/widgets/player/player_loading_overlay.dart b/lib/widgets/player/player_loading_overlay.dart index 50c3063a..54f83214 100644 --- a/lib/widgets/player/player_loading_overlay.dart +++ b/lib/widgets/player/player_loading_overlay.dart @@ -1,6 +1,8 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../services/layout/form_factor.dart'; +import '../../services/storage/app_image_cache.dart'; import '../../services/theme/design_tokens.dart'; import '../common/focusable_card.dart'; import 'player_glass.dart'; @@ -142,9 +144,10 @@ class _PlayerLoadingOverlayState extends State if (widget.backdropUrl != null) Opacity( opacity: 0.4, - child: Image.network( - widget.backdropUrl!, - cacheWidth: 1280, + child: CachedNetworkImage( + imageUrl: widget.backdropUrl!, + cacheManager: AppImageCache.manager, + memCacheWidth: 1280, fit: BoxFit.cover, ), ), @@ -175,9 +178,10 @@ class _PlayerLoadingOverlayState extends State ), ); }, - child: Image.network( - widget.logoUrl!, - cacheHeight: 300, + child: CachedNetworkImage( + imageUrl: widget.logoUrl!, + cacheManager: AppImageCache.manager, + memCacheHeight: 300, height: 100, fit: BoxFit.contain, ), From 0e57390de006f3b7b2822464b402e1a463ff0537 Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 09:46:48 -0300 Subject: [PATCH 15/19] fix: retain prior custom download roots for task validation --- lib/services/download/download_service.dart | 10 +++--- lib/utils/download/download_path_helper.dart | 37 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/lib/services/download/download_service.dart b/lib/services/download/download_service.dart index 47404a54..c73282c5 100644 --- a/lib/services/download/download_service.dart +++ b/lib/services/download/download_service.dart @@ -66,15 +66,17 @@ class DownloadService { final List jsonList = jsonDecode(content); final tasks = []; - final downloadsRoot = - await DownloadPathHelper.getDownloadsDirectoryPath(); + final downloadRoots = + await DownloadPathHelper.getTrustedDownloadDirectories(); for (final item in jsonList) { if (item is Map) { var task = DownloadTask.fromJson(item); - if (!isWithinDirectory(downloadsRoot, task.targetFilePath)) { + if (!downloadRoots.any( + (root) => isWithinDirectory(root, task.targetFilePath), + )) { debugPrint( - '[DownloadService] Rejected download path outside $downloadsRoot', + '[DownloadService] Rejected download path outside configured roots', ); task = task.copyWith( status: DownloadStatus.failed, diff --git a/lib/utils/download/download_path_helper.dart b/lib/utils/download/download_path_helper.dart index 7ae83524..7fd63af4 100644 --- a/lib/utils/download/download_path_helper.dart +++ b/lib/utils/download/download_path_helper.dart @@ -7,6 +7,9 @@ import 'package:shared_preferences/shared_preferences.dart'; class DownloadPathHelper { static const String _customPathKey = 'custom_downloads_directory'; + static const String _customPathHistoryKey = + 'custom_downloads_directory_history'; + static const int _maxRememberedDirectories = 10; /// Launches the native system folder picker so user can pick where to save downloads. static Future pickDownloadsDirectory() async { @@ -47,6 +50,40 @@ class DownloadPathHelper { static Future setCustomDownloadsDirectoryPath(String path) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_customPathKey, path); + + // Keep a bounded history of user-picked folders so downloads started in a + // previously selected folder can still be validated and resumed after the + // user picks a different one. + final normalized = p.normalize(p.absolute(path)); + final history = prefs.getStringList(_customPathHistoryKey) ?? const []; + final updated = [normalized]; + for (final previous in history) { + final previousNormalized = p.normalize(p.absolute(previous)); + if (previousNormalized != normalized && + !updated.contains(previousNormalized)) { + updated.add(previousNormalized); + } + if (updated.length >= _maxRememberedDirectories) break; + } + await prefs.setStringList(_customPathHistoryKey, updated); + } + + /// Download roots the app itself recorded, used to validate persisted task + /// paths without trusting the task file's own directory string. + static Future> getTrustedDownloadDirectories() async { + final prefs = await SharedPreferences.getInstance(); + final roots = { + await getDownloadsDirectoryPath(), + await getDefaultDownloadsDirectoryPath(), + }; + + final current = prefs.getString(_customPathKey); + if (current != null && current.isNotEmpty) roots.add(current); + + final history = prefs.getStringList(_customPathHistoryKey) ?? const []; + roots.addAll(history.where((path) => path.isNotEmpty)); + + return roots.toList(); } /// Returns the platform default download directory. From dd5478f4169f435e4cb5544cee1b6122c78ec559 Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 09:47:49 -0300 Subject: [PATCH 16/19] security: redact remaining scraper and subtitle URLs --- lib/services/scraper/sites/fourkhdhub.dart | 23 ++++++++++--------- .../subtitles/subtitle_extractor.dart | 5 ++-- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/services/scraper/sites/fourkhdhub.dart b/lib/services/scraper/sites/fourkhdhub.dart index 3e840c5b..3176325c 100644 --- a/lib/services/scraper/sites/fourkhdhub.dart +++ b/lib/services/scraper/sites/fourkhdhub.dart @@ -4,6 +4,7 @@ import 'package:html/parser.dart' as html_parser; import 'package:html/dom.dart' as dom; import '../stream_scraper.dart'; import '../../../models/stream/stream_model.dart'; +import '../../../utils/security/log_redaction.dart'; /// 1:1 port of webstreamr-0.69.1 src/source/FourKHDHub.ts /// + src/source/hd-hub-helper.ts (resolveRedirectUrl) @@ -89,7 +90,7 @@ class FourKHDHubScraper extends StreamScraper { return sources; } - print('[4KHDHub] Fetching detail page: $pageUrl'); + print('[4KHDHub] Fetching detail page: ${redactUrl(pageUrl)}'); final html = await _fetchText(pageUrl); final doc = html_parser.parse(html); @@ -123,7 +124,7 @@ class FourKHDHubScraper extends StreamScraper { } } } catch (e, stack) { - print('[4KHDHub ERROR] Scrape failed: $e\n$stack'); + print('[4KHDHub ERROR] Scrape failed: ${redactTextUrls('$e')}\n$stack'); } print('[4KHDHub] Scrape completed. Found ${sources.length} active sources for "$title"'); @@ -139,7 +140,7 @@ class FourKHDHubScraper extends StreamScraper { try { final baseUrl = await _getBaseUrl(); final searchUrl = '$baseUrl/?s=${Uri.encodeComponent(name)}'; - print('[4KHDHub] Searching $searchUrl'); + print('[4KHDHub] Searching ${redactUrl(searchUrl)}'); final html = await _fetchText(searchUrl); final doc = html_parser.parse(html); @@ -171,11 +172,11 @@ class FourKHDHubScraper extends StreamScraper { if (href == null) continue; final fullUrl = Uri.parse(baseUrl).resolve(href).toString(); - print('[4KHDHub] Matched page card "$cardTitle" -> $fullUrl'); + print('[4KHDHub] Matched page card "$cardTitle" -> ${redactUrl(fullUrl)}'); return fullUrl; } } catch (e) { - print('[4KHDHub ERROR] fetchPageUrl error: $e'); + print('[4KHDHub ERROR] fetchPageUrl error: ${redactTextUrls('$e')}'); } return null; } @@ -218,14 +219,14 @@ class FourKHDHubScraper extends StreamScraper { if (redirectUrl == null) return sources; try { - print('[4KHDHub] Resolving redirect link: $redirectUrl'); + print('[4KHDHub] Resolving redirect link: ${redactUrl(redirectUrl)}'); final resolvedUrl = await _resolveRedirectUrl(redirectUrl); if (resolvedUrl != null) { final safeUrl = _sanitizeStreamUrl(resolvedUrl); // Perform quick stream health check to prevent 403 quota / HTML error pages from crashing player final isValid = await _validateStreamUrl(safeUrl); if (isValid) { - print('[4KHDHub SUCCESS] Added valid stream source: $safeUrl'); + print('[4KHDHub SUCCESS] Added valid stream source: ${redactUrl(safeUrl)}'); sources.add(StreamSource( name: 'ZPlayHTTP', addonName: 'ZPlayHTTP', @@ -233,14 +234,14 @@ class FourKHDHubScraper extends StreamScraper { url: safeUrl, )); } else { - print('[4KHDHub FILTERED] Skipped invalid/quota-exceeded stream URL: $safeUrl'); + print('[4KHDHub FILTERED] Skipped invalid/quota-exceeded stream URL: ${redactUrl(safeUrl)}'); } } } catch (e) { - print('[4KHDHub ERROR] resolveRedirectUrl error: $e'); + print('[4KHDHub ERROR] resolveRedirectUrl error: ${redactTextUrls('$e')}'); } } catch (e) { - print('[4KHDHub ERROR] extract source error: $e'); + print('[4KHDHub ERROR] extract source error: ${redactTextUrls('$e')}'); } return sources; } @@ -335,7 +336,7 @@ class FourKHDHubScraper extends StreamScraper { print('4khdhub final link not found in intermediate page'); return null; } catch (e) { - print('4khdhub resolveRedirectUrl error: $e'); + print('4khdhub resolveRedirectUrl error: ${redactTextUrls('$e')}'); return null; } } diff --git a/lib/services/subtitles/subtitle_extractor.dart b/lib/services/subtitles/subtitle_extractor.dart index d6c828d4..fdaad5c6 100644 --- a/lib/services/subtitles/subtitle_extractor.dart +++ b/lib/services/subtitles/subtitle_extractor.dart @@ -4,6 +4,7 @@ import 'package:http/http.dart' as http; import 'package:archive/archive.dart'; import 'package:path_provider/path_provider.dart'; +import '../../utils/security/log_redaction.dart'; import 'subtitle_parser.dart'; class SubtitleExtractor { @@ -33,7 +34,7 @@ class SubtitleExtractor { .timeout(const Duration(seconds: 15)); if (response.statusCode != 200) { - print('[SubtitleExtractor] Download failed for $url (Status: ${response.statusCode})'); + print('[SubtitleExtractor] Download failed for ${redactUrl(url)} (Status: ${response.statusCode})'); return null; } @@ -139,7 +140,7 @@ class SubtitleExtractor { print('[SubtitleExtractor SUCCESS] Extracted subtitle to $savePath (${utf8Bytes.length} bytes)'); return savePath; } catch (e, st) { - print('[SubtitleExtractor ERROR] Subtitle extraction error ($providerName): $e\n$st'); + print('[SubtitleExtractor ERROR] Subtitle extraction error ($providerName): ${redactTextUrls('$e')}\n${redactTextUrls('$st')}'); } return null; } From 1de3ba8876c0d5e1c70bf4b37d6489a6419d5b13 Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 09:58:00 -0300 Subject: [PATCH 17/19] test: isolate startup P2P advisory from shell tests --- test/pages/home/home_chrome_test.dart | 4 +++- test/pages/home/home_hero_carousel_reset_test.dart | 4 +++- test/pages/home/home_hero_focus_traversal_test.dart | 4 +++- test/pages/home/home_hero_swipe_test.dart | 4 +++- test/shell/live_tv_shortcut_test.dart | 4 +++- test/shell/page_controls_stay_focusable_test.dart | 4 +++- test/shell/phone_layout_fit_test.dart | 4 +++- test/shell/top_bar_traversal_test.dart | 4 +++- test/shell_back_press_test.dart | 4 +++- test/tv_dpad_focus_regression_test.dart | 4 +++- test/tv_home_chrome_reachability_test.dart | 4 +++- 11 files changed, 33 insertions(+), 11 deletions(-) diff --git a/test/pages/home/home_chrome_test.dart b/test/pages/home/home_chrome_test.dart index 207e25c6..59f87ecc 100644 --- a/test/pages/home/home_chrome_test.dart +++ b/test/pages/home/home_chrome_test.dart @@ -162,7 +162,9 @@ Future _settle(WidgetTester tester) async { void main() { setUp(() async { - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues({ + 'zplay_p2p_warning_never_show': true, + }); AddonManager.readStoredForTesting = () async => _probeAddon; await AddonManager.instance.reload(); DeviceProfile.debugSetTelevision(value: true); diff --git a/test/pages/home/home_hero_carousel_reset_test.dart b/test/pages/home/home_hero_carousel_reset_test.dart index a7bd415c..62b64bec 100644 --- a/test/pages/home/home_hero_carousel_reset_test.dart +++ b/test/pages/home/home_hero_carousel_reset_test.dart @@ -330,7 +330,9 @@ String _currentHeroTitle(WidgetTester tester) { void main() { setUp(() async { _useTelevisionHighlight(); - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues({ + 'zplay_p2p_warning_never_show': true, + }); // One probe addon, so Home has a hero and rails driven entirely by this // file's scripted client and nothing else. AddonManager.readStoredForTesting = () async => _probeAddon; diff --git a/test/pages/home/home_hero_focus_traversal_test.dart b/test/pages/home/home_hero_focus_traversal_test.dart index c473fb24..23d839ff 100644 --- a/test/pages/home/home_hero_focus_traversal_test.dart +++ b/test/pages/home/home_hero_focus_traversal_test.dart @@ -197,7 +197,9 @@ Future _settleAtHeroCta(WidgetTester tester) async { void main() { setUp(() async { _useTelevisionHighlight(); - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues({ + 'zplay_p2p_warning_never_show': true, + }); // One probe addon, so Home has a hero and rails driven entirely by this // file's scripted client and nothing else. AddonManager.readStoredForTesting = () async => _probeAddon; diff --git a/test/pages/home/home_hero_swipe_test.dart b/test/pages/home/home_hero_swipe_test.dart index 4bff6b3f..3ebf9753 100644 --- a/test/pages/home/home_hero_swipe_test.dart +++ b/test/pages/home/home_hero_swipe_test.dart @@ -384,7 +384,9 @@ Future _disarmRotation(WidgetTester tester) async { void main() { setUp(() async { - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues({ + 'zplay_p2p_warning_never_show': true, + }); // One probe addon, so Home has a hero and rails driven entirely by this // file's scripted client and nothing else. AddonManager.readStoredForTesting = () async => _probeAddon; diff --git a/test/shell/live_tv_shortcut_test.dart b/test/shell/live_tv_shortcut_test.dart index 2c55a069..e8d25da2 100644 --- a/test/shell/live_tv_shortcut_test.dart +++ b/test/shell/live_tv_shortcut_test.dart @@ -32,7 +32,9 @@ void main() { testWidgets('the bar\'s Live TV destination selects the liveTv vertical', (tester) async { - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues({ + 'zplay_p2p_warning_never_show': true, + }); DeviceProfile.debugSetTelevision(value: true); tester.view.devicePixelRatio = 2.0; tester.view.physicalSize = const Size(960, 540) * 2.0; diff --git a/test/shell/page_controls_stay_focusable_test.dart b/test/shell/page_controls_stay_focusable_test.dart index edc2542a..4d99559b 100644 --- a/test/shell/page_controls_stay_focusable_test.dart +++ b/test/shell/page_controls_stay_focusable_test.dart @@ -38,7 +38,9 @@ void main() { testWidgets('tapping the search field in the shell focuses it and types', (tester) async { - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues({ + 'zplay_p2p_warning_never_show': true, + }); tester.view.physicalSize = const Size(1440, 3000); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); diff --git a/test/shell/phone_layout_fit_test.dart b/test/shell/phone_layout_fit_test.dart index c036e950..17f01002 100644 --- a/test/shell/phone_layout_fit_test.dart +++ b/test/shell/phone_layout_fit_test.dart @@ -26,7 +26,9 @@ void main() { for (final width in const [360.0, 412.0]) { testWidgets('the shell fits a $width dp phone without overflowing', (tester) async { - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues({ + 'zplay_p2p_warning_never_show': true, + }); tester.view.physicalSize = Size(width * 3, 800 * 3); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); diff --git a/test/shell/top_bar_traversal_test.dart b/test/shell/top_bar_traversal_test.dart index 9cac4e06..a2c75af8 100644 --- a/test/shell/top_bar_traversal_test.dart +++ b/test/shell/top_bar_traversal_test.dart @@ -33,7 +33,9 @@ void main() { testWidgets('up reaches the bar, left/right walk it, down returns to the page', (tester) async { - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues({ + 'zplay_p2p_warning_never_show': true, + }); DeviceProfile.debugSetTelevision(value: true); tester.view.devicePixelRatio = 2.0; tester.view.physicalSize = const Size(960, 540) * 2.0; diff --git a/test/shell_back_press_test.dart b/test/shell_back_press_test.dart index 9d13268f..44b7fcdf 100644 --- a/test/shell_back_press_test.dart +++ b/test/shell_back_press_test.dart @@ -61,7 +61,9 @@ bool _leftTheApp(List calls) => /// (`test/tv_chrome_density_test.dart` pumps it for the same reason), and it is /// spent before any press, so it never consumes part of the exit window. Future _pumpShell(WidgetTester tester) async { - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues({ + 'zplay_p2p_warning_never_show': true, + }); await tester.pumpWidget(const MaterialApp(home: AppShell())); await tester.pump(const Duration(seconds: 2)); } diff --git a/test/tv_dpad_focus_regression_test.dart b/test/tv_dpad_focus_regression_test.dart index b5c88922..b99c1700 100644 --- a/test/tv_dpad_focus_regression_test.dart +++ b/test/tv_dpad_focus_regression_test.dart @@ -305,7 +305,9 @@ void main() { setUp(() async { FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues({ + 'zplay_p2p_warning_never_show': true, + }); MetadataService.client = MockClient((request) async { if (request.url.path.contains('/catalog/')) { final type = request.url.pathSegments.firstWhere( diff --git a/test/tv_home_chrome_reachability_test.dart b/test/tv_home_chrome_reachability_test.dart index 2e067cc0..5d024328 100644 --- a/test/tv_home_chrome_reachability_test.dart +++ b/test/tv_home_chrome_reachability_test.dart @@ -251,7 +251,9 @@ void main() { setUp(() async { FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues({ + 'zplay_p2p_warning_never_show': true, + }); MetadataService.client = MockClient((request) async { if (request.url.path.contains('/catalog/')) { return http.Response(_catalogBody(), 200); From 98a29a08790537d6ab87fd5b879c0f24bbc31e64 Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 10:44:40 -0300 Subject: [PATCH 18/19] fix: address security and review findings --- .../audiobooks/audiobook_player_screen.dart | 16 +++-- lib/pages/books/book_detail_sheet.dart | 13 ++-- lib/pages/home/home_page.dart | 2 +- lib/pages/player/player_screen.dart | 24 ++++--- lib/services/books/book_download_service.dart | 2 + lib/services/books/bookracy_service.dart | 3 +- .../cloudstream/cloudstream_manager.dart | 69 ++++++++++--------- lib/services/download/download_service.dart | 16 +++++ lib/services/scraper/sites/cinejoy.dart | 5 +- lib/services/scraper/sites/hindmoviez.dart | 16 ++--- .../providers/stremio_subtitle_provider.dart | 5 +- .../books/book_download_service_test.dart | 16 +++++ .../download/download_path_helper_test.dart | 32 +++++++++ 13 files changed, 156 insertions(+), 63 deletions(-) create mode 100644 test/utils/download/download_path_helper_test.dart diff --git a/lib/pages/audiobooks/audiobook_player_screen.dart b/lib/pages/audiobooks/audiobook_player_screen.dart index 05fec0ab..7a0eec65 100644 --- a/lib/pages/audiobooks/audiobook_player_screen.dart +++ b/lib/pages/audiobooks/audiobook_player_screen.dart @@ -169,7 +169,7 @@ class _AudiobookPlayerScreenState extends State // The first chapter has nowhere to go back to, so the button restarts it, // which is what a listener reaching for it at the start of a book expects. if (_currentChapterIndex == 0) { - await _player?.seek(Duration.zero); + await seek(Duration.zero); return; } await _initChapter(_currentChapterIndex - 1); @@ -180,6 +180,7 @@ class _AudiobookPlayerScreenState extends State await _player?.seek(position); if (!mounted) return; setState(() => _position = position); + _saveProgress(); } /// Stops the book and removes the bar, by leaving this screen. @@ -424,6 +425,7 @@ class _AudiobookPlayerScreenState extends State _player!.pause(); _discAnimController.stop(); setState(() => _isPlaying = false); + _saveProgress(); } else { _player!.play(); _discAnimController.repeat(); @@ -441,6 +443,8 @@ class _AudiobookPlayerScreenState extends State ? Duration.zero : (target > _duration ? _duration : target); _player!.seek(clamped); + if (mounted) setState(() => _position = clamped); + _saveProgress(); } void _cycleSpeed() { @@ -1139,7 +1143,7 @@ class _AudiobookPlayerScreenState extends State duration: _duration, isPlaying: _isPlaying, style: seekStyle, - onSeek: (pos) => _player?.seek(pos), + onSeek: (pos) => seek(pos), ), const SizedBox(height: 12), @@ -1192,7 +1196,7 @@ class _AudiobookPlayerScreenState extends State duration: _duration, isPlaying: _isPlaying, style: AudiobookSeekbarStyle.audioWaveformCanvas, - onSeek: (pos) => _player?.seek(pos), + onSeek: (pos) => seek(pos), ), const SizedBox(height: 14), @@ -1298,7 +1302,7 @@ class _AudiobookPlayerScreenState extends State duration: _duration, isPlaying: _isPlaying, style: AudiobookSeekbarStyle.gradientProgress, - onSeek: (pos) => _player?.seek(pos), + onSeek: (pos) => seek(pos), ), const SizedBox(height: 12), _buildMainControlsCluster(palette), @@ -1342,7 +1346,7 @@ class _AudiobookPlayerScreenState extends State duration: _duration, isPlaying: _isPlaying, style: AudiobookSeekbarStyle.audioWaveformCanvas, - onSeek: (pos) => _player?.seek(pos), + onSeek: (pos) => seek(pos), ), const SizedBox(height: 16), _buildMainControlsCluster(palette), @@ -1409,7 +1413,7 @@ class _AudiobookPlayerScreenState extends State duration: _duration, isPlaying: _isPlaying, style: seekStyle, - onSeek: (pos) => _player?.seek(pos), + onSeek: (pos) => seek(pos), ), ); case 'mainControls': diff --git a/lib/pages/books/book_detail_sheet.dart b/lib/pages/books/book_detail_sheet.dart index bef46919..9a2992ed 100644 --- a/lib/pages/books/book_detail_sheet.dart +++ b/lib/pages/books/book_detail_sheet.dart @@ -48,10 +48,15 @@ class _BookDetailSheetState extends State { } Future _checkDownloadStatus() async { - final downloaded = await BookDownloadService.instance.getDownloadedBook( - widget.book.md5, - widget.book.bookFiletype, - ); + File? downloaded; + try { + downloaded = await BookDownloadService.instance.getDownloadedBook( + widget.book.md5, + widget.book.bookFiletype, + ); + } catch (e) { + debugPrint('[BookDetailSheet] Could not check download status: $e'); + } if (!mounted) return; setState(() { _isDownloaded = downloaded != null; diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index 88541e01..49b0cb5b 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -714,12 +714,12 @@ class _HomePageState extends State { _traktPublicSections ..clear() ..addAll(fetched.whereType()); + _traktPublicLoaded = true; }); } catch (e) { debugPrint('[HomePage] Trakt public sections failed: $e'); } finally { _traktPublicInFlight = false; - _traktPublicLoaded = true; } } diff --git a/lib/pages/player/player_screen.dart b/lib/pages/player/player_screen.dart index 8344920f..8755eee5 100644 --- a/lib/pages/player/player_screen.dart +++ b/lib/pages/player/player_screen.dart @@ -1194,9 +1194,11 @@ class _PlayerScreenState extends State { playerHeaders.addAll(Map.from(proxyReqHeaders)); } + final cleanUri = Uri.parse(sanitizedUrlStr); _activeStreamUrl = sanitizedUrlStr; print( - '[PlayerScreen] Opening direct network stream URL: ${redactUrl(sanitizedUrlStr)} (headers: ${playerHeaders.keys})', + '[PlayerScreen] Opening direct network stream URL: ${redactUrl(cleanUri.toString())} ' + '(headers: ${playerHeaders.keys})', ); if (!mounted) return; @@ -1237,7 +1239,7 @@ class _PlayerScreenState extends State { CrashBreadcrumbs.stream('open.start', title: _currentTitle); final bool usedMedia3 = await _openSource( ve.EngineRequest( - url: sanitizedUrlStr, + url: cleanUri.toString(), headers: isTorrentStream ? const {} : playerHeaders, start: seekTarget ?? Duration.zero, isLive: isLive, @@ -1343,15 +1345,17 @@ class _PlayerScreenState extends State { return; } + final redactedError = redactTextUrls('$e'); + final redactedStack = redactTextUrls('$stackTrace'); CrashBreadcrumbs.error( - e, + redactedError, context: 'PlayerScreen.initStream $_currentTitle', ); print( '[PlayerScreen ERROR] Failed to initialize stream URL: "${redactUrl(streamUrl)}"', ); - print('[PlayerScreen ERROR] Exception: $e'); - print('[PlayerScreen ERROR] StackTrace:\n$stackTrace'); + print('[PlayerScreen ERROR] Exception: $redactedError'); + print('[PlayerScreen ERROR] StackTrace:\n$redactedStack'); if (!mounted) return; @@ -1394,7 +1398,7 @@ class _PlayerScreenState extends State { // The short message is what the screen may show. The raw status and body fit // in the breadcrumb context, which is where the detail belongs. CrashBreadcrumbs.error( - e, + redactTextUrls('$e'), context: '${e.service} ${e.kind.name} ${redactTextUrls(e.detail)}', ); print( @@ -2797,7 +2801,7 @@ class _PlayerScreenState extends State { // 5. Critical error on dead stream CrashBreadcrumbs.error( - errorMsg, + redactTextUrls(errorMsg), context: 'PlayerScreen.playback $_currentTitle', ); print( @@ -2909,9 +2913,11 @@ class _PlayerScreenState extends State { // detection armed (open watchdog plus frame watchdog) so the user gets the // recovery overlay with its escape actions instead of a silent spinner, // and never report this as a seek problem. - debugPrint('[PlayerScreen] Reopen without a start position failed: $e'); + debugPrint( + '[PlayerScreen] Reopen without a start position failed: ${redactTextUrls('$e')}', + ); CrashBreadcrumbs.error( - e, + redactTextUrls('$e'), context: 'PlayerScreen.recoverFromRefusedSeek $_currentTitle', ); _startFrameWatchdog(); diff --git a/lib/services/books/book_download_service.dart b/lib/services/books/book_download_service.dart index 63c83c1c..c1e268b8 100644 --- a/lib/services/books/book_download_service.dart +++ b/lib/services/books/book_download_service.dart @@ -57,6 +57,7 @@ class BookDownloadService { /// Checks if the book is already downloaded and exists on disk. Future isBookDownloaded(String md5, String filetype) async { + if (!_md5Pattern.hasMatch(md5.trim())) return false; final file = await getBookFile(md5, filetype); if (await file.exists()) { final len = await file.length(); @@ -67,6 +68,7 @@ class BookDownloadService { /// Gets the local file if downloaded, or null if not yet downloaded. Future getDownloadedBook(String md5, String filetype) async { + if (!_md5Pattern.hasMatch(md5.trim())) return null; final file = await getBookFile(md5, filetype); if (await file.exists() && await file.length() > 100) { return file; diff --git a/lib/services/books/bookracy_service.dart b/lib/services/books/bookracy_service.dart index 553c6be7..219aa82b 100644 --- a/lib/services/books/bookracy_service.dart +++ b/lib/services/books/bookracy_service.dart @@ -6,6 +6,7 @@ import '../../models/book/book_result.dart'; class BookracyService { static const String _baseUrl = 'https://api.bookracy.com/api/books'; static final BookracyService instance = BookracyService._(); + static final RegExp _md5Pattern = RegExp(r'^[0-9a-fA-F]{32}$'); BookracyService._(); @@ -76,7 +77,7 @@ class BookracyService { } if (book.title.isNotEmpty && - book.md5.isNotEmpty && + _md5Pattern.hasMatch(book.md5.trim()) && seenMd5.add(book.md5)) { // Apply format filter if specified (e.g. 'epub', 'pdf', 'mobi', 'azw3', 'fb2', 'txt', 'cbz') if (formatFilter != null && diff --git a/lib/services/cloudstream/cloudstream_manager.dart b/lib/services/cloudstream/cloudstream_manager.dart index 482504bf..2859edb4 100644 --- a/lib/services/cloudstream/cloudstream_manager.dart +++ b/lib/services/cloudstream/cloudstream_manager.dart @@ -721,31 +721,49 @@ class CloudStreamManager { } Set _extensionFileNames(CloudStreamSource source) { - return { - for (final name in [ - source.name, - if (source.internalName != null) source.internalName!, - ]) - sanitizeFileComponent(name, fallback: ''), - }..remove(''); + final names = {}; + for (final name in [ + source.name, + if (source.internalName != null) source.internalName!, + ]) { + final sanitized = sanitizeFileComponent(name, fallback: ''); + if (sanitized.isNotEmpty) names.add(sanitized); + + // Extensions installed before sanitization used the raw name. Keep + // looking for it so an update does not strand the old jar, but only when + // it is a single safe path component. + if (name.isNotEmpty && + name != '.' && + name != '..' && + !p.isAbsolute(name) && + p.basename(name) == name && + !(Platform.isWindows && name.contains(':'))) { + names.add(name); + } + } + return names; + } + + Future _deleteExtensionFiles( + Directory extDir, + Set candidates, + ) async { + for (final baseName in candidates) { + try { + final jarFile = File(p.join(extDir.path, '$baseName.jar')); + if (await jarFile.exists()) await jarFile.delete(); + + final cs3File = File(p.join(extDir.path, '$baseName.cs3')); + if (await cs3File.exists()) await cs3File.delete(); + } catch (_) {} + } } Future uninstallExtension(CloudStreamSource source) async { final extDir = await CloudStreamPaths.instance.extensionsDir; final candidates = _extensionFileNames(source); - - for (final baseName in candidates) { - final jarFile = File(p.join(extDir.path, '$baseName.jar')); - if (await jarFile.exists()) { - try { await jarFile.delete(); } catch (_) {} - } - - final cs3File = File(p.join(extDir.path, '$baseName.cs3')); - if (await cs3File.exists()) { - try { await cs3File.delete(); } catch (_) {} - } - } + await _deleteExtensionFiles(extDir, candidates); try { await CloudStreamDispatcher.instance.unloadExtension(source.internalName ?? source.name); @@ -771,18 +789,7 @@ class CloudStreamManager { final extDir = await CloudStreamPaths.instance.extensionsDir; for (final source in sources) { final candidates = _extensionFileNames(source); - - for (final baseName in candidates) { - final jarFile = File(p.join(extDir.path, '$baseName.jar')); - if (await jarFile.exists()) { - try { await jarFile.delete(); } catch (_) {} - } - - final cs3File = File(p.join(extDir.path, '$baseName.cs3')); - if (await cs3File.exists()) { - try { await cs3File.delete(); } catch (_) {} - } - } + await _deleteExtensionFiles(extDir, candidates); try { await CloudStreamDispatcher.instance.unloadExtension(source.internalName ?? source.name); diff --git a/lib/services/download/download_service.dart b/lib/services/download/download_service.dart index c73282c5..c108823c 100644 --- a/lib/services/download/download_service.dart +++ b/lib/services/download/download_service.dart @@ -263,6 +263,17 @@ class DownloadService { } Future _executeDownload(DownloadTask task) async { + if (!await _isTargetPathAllowed(task.targetFilePath)) { + debugPrint( + '[DownloadService] Refused to execute download outside configured roots', + ); + _updateTask(task.copyWith( + status: DownloadStatus.failed, + error: 'Invalid download path', + )); + return; + } + _canceledOrPausedTaskIds.remove(task.id); _updateTask(task.copyWith(status: DownloadStatus.downloading, error: null)); @@ -283,6 +294,11 @@ class DownloadService { } } + Future _isTargetPathAllowed(String targetPath) async { + final roots = await DownloadPathHelper.getTrustedDownloadDirectories(); + return roots.any((root) => isWithinDirectory(root, targetPath)); + } + // ── Engine 1: TorrServer P2P Swarm Download ─────────────────────────────── Future _executeP2PDownload(DownloadTask task) async { diff --git a/lib/services/scraper/sites/cinejoy.dart b/lib/services/scraper/sites/cinejoy.dart index d9785633..384e78da 100644 --- a/lib/services/scraper/sites/cinejoy.dart +++ b/lib/services/scraper/sites/cinejoy.dart @@ -6,6 +6,7 @@ import 'package:http/http.dart' as http; import 'package:pointycastle/export.dart'; import '../stream_scraper.dart'; import '../../../models/stream/stream_model.dart'; +import '../../../utils/security/log_redaction.dart'; import 'tmdb_helper.dart'; /// Pure-Dart Cinejoy Stream Scraper for ZPlayHTTP. @@ -314,7 +315,7 @@ class CinejoyScraper extends StreamScraper { final streamTitle = '[Cinejoy - $srvName] $quality'; final desc = '$srvName • $quality • HLS'; - debugPrint('[CinejoyScraper SUCCESS] Added stream source from $srvName: $playlistUrl'); + debugPrint('[CinejoyScraper SUCCESS] Added stream source from $srvName: ${redactUrl(playlistUrl)}'); if (!controller.isClosed) { controller.add( @@ -347,7 +348,7 @@ class CinejoyScraper extends StreamScraper { final format = (qVal['type'] ?? 'mp4').toString().toUpperCase(); final desc = '$label • $quality • $format'; - debugPrint('[CinejoyScraper SUCCESS] Added MP4 source from $label: $fileUrl'); + debugPrint('[CinejoyScraper SUCCESS] Added MP4 source from $label: ${redactUrl(fileUrl)}'); if (!controller.isClosed) { controller.add( diff --git a/lib/services/scraper/sites/hindmoviez.dart b/lib/services/scraper/sites/hindmoviez.dart index 95bc53c5..f29942e4 100644 --- a/lib/services/scraper/sites/hindmoviez.dart +++ b/lib/services/scraper/sites/hindmoviez.dart @@ -292,14 +292,14 @@ class HindMoviezScraper extends StreamScraper { }) async { final sources = []; try { - debugPrint('[HindMoviez] Fetching MvLink: $mvlinkUrl'); + debugPrint('[HindMoviez] Fetching MvLink: ${redactUrl(mvlinkUrl)}'); final res = await http.get( Uri.parse(mvlinkUrl), headers: {'User-Agent': _defaultUa, 'Referer': referer}, ).timeout(const Duration(seconds: 10)); if (res.statusCode != 200) { - debugPrint('[HindMoviez] MvLink status ${res.statusCode} for $mvlinkUrl'); + debugPrint('[HindMoviez] MvLink status ${res.statusCode} for ${redactUrl(mvlinkUrl)}'); return sources; } final mvHtml = res.body; @@ -315,7 +315,7 @@ class HindMoviezScraper extends StreamScraper { } } - debugPrint('[HindMoviez] Found ${hshareIds.length} HShare IDs in $mvlinkUrl'); + debugPrint('[HindMoviez] Found ${hshareIds.length} HShare IDs in ${redactUrl(mvlinkUrl)}'); if (hshareIds.isEmpty) return sources; if (isTv && episode != null) { @@ -379,7 +379,7 @@ class HindMoviezScraper extends StreamScraper { try { // 1. Bypass via admin-ajax.php final rUrl = await _bypassHShareAPI(hshareId, mvlinkUrl); - debugPrint('[HindMoviez] Bypassed HShare API -> rUrl: $rUrl'); + debugPrint('[HindMoviez] Bypassed HShare API -> rUrl: ${redactUrl(rUrl)}'); if (rUrl == null || rUrl.isEmpty) return sources; // 2. Fetch r.php and solve challenge if needed @@ -399,9 +399,9 @@ class HindMoviezScraper extends StreamScraper { int serverNum = 1; for (final hm in hcloudMatches) { final hcloudUrl = hm.group(1)!; - debugPrint('[HindMoviez] Resolving hcloud URL: $hcloudUrl'); + debugPrint('[HindMoviez] Resolving hcloud URL: ${redactUrl(hcloudUrl)}'); final directUrl = await _resolveHCloudUrl(hcloudUrl, rUrl); - debugPrint('[HindMoviez] Resolved direct stream: $directUrl'); + debugPrint('[HindMoviez] Resolved direct stream: ${redactUrl(directUrl)}'); if (directUrl != null && directUrl.isNotEmpty) { final serverLabel = 'Server $serverNum'; final source = _buildStreamSource( @@ -471,7 +471,7 @@ class HindMoviezScraper extends StreamScraper { } // 2. Initial request (follow redirects false, without cookie) - debugPrint('[HindMoviez] Fetching rUrl: $rUrl'); + debugPrint('[HindMoviez] Fetching rUrl: ${redactUrl(rUrl)}'); final initialRes = await _getWithCookieAndRedirects( url: rUrl, referer: referer, @@ -663,7 +663,7 @@ class HindMoviezScraper extends StreamScraper { // 11. Send Challenge GET Request final challengeUrl = 'https://hshare.ink$actionPath?wsidchk=$wsidchk&pdata=$pdata&id=$idVal&ts=$ts&cttl=0'; - debugPrint('[HindMoviez] Submitting challenge GET: $challengeUrl'); + debugPrint('[HindMoviez] Submitting challenge GET: ${redactUrl(challengeUrl)}'); final client = http.Client(); try { diff --git a/lib/services/subtitles/providers/stremio_subtitle_provider.dart b/lib/services/subtitles/providers/stremio_subtitle_provider.dart index 9648497f..dc3b1493 100644 --- a/lib/services/subtitles/providers/stremio_subtitle_provider.dart +++ b/lib/services/subtitles/providers/stremio_subtitle_provider.dart @@ -4,6 +4,7 @@ import 'package:http/http.dart' as http; import '../../../models/subtitle/subtitle_model.dart'; import '../../addon/addon_manager.dart'; import '../../addon/addon_url_resolver.dart'; +import '../../../utils/security/log_redaction.dart'; import '../subtitle_provider.dart'; import '../subtitle_extractor.dart'; @@ -167,7 +168,9 @@ class StremioSubtitleProvider extends SubtitleProvider { try { final resolvedBaseUrl = await AddonUrlResolver.resolve(addon.baseUrl); final url = '$resolvedBaseUrl/subtitles/$type/$id.json'; - print('[StremioSubtitleProvider] Querying ${addon.manifest.name}: $url'); + print( + '[StremioSubtitleProvider] Querying ${addon.manifest.name}: ${redactUrl(url)}', + ); final res = await http .get(Uri.parse(url), headers: _headers) .timeout(const Duration(seconds: 6)); diff --git a/test/services/books/book_download_service_test.dart b/test/services/books/book_download_service_test.dart index c163971f..a1e74f09 100644 --- a/test/services/books/book_download_service_test.dart +++ b/test/services/books/book_download_service_test.dart @@ -17,4 +17,20 @@ void main() { ); }); }); + + group('BookDownloadService download queries', () { + test('treat a malformed id as not downloaded', () async { + expect( + await BookDownloadService.instance.getDownloadedBook( + '../../evil', + 'epub', + ), + isNull, + ); + expect( + await BookDownloadService.instance.isBookDownloaded('abc123', 'epub'), + isFalse, + ); + }); + }); } diff --git a/test/utils/download/download_path_helper_test.dart b/test/utils/download/download_path_helper_test.dart new file mode 100644 index 00000000..50711e01 --- /dev/null +++ b/test/utils/download/download_path_helper_test.dart @@ -0,0 +1,32 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:zplay/utils/download/download_path_helper.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('records prior custom download roots without duplicates', () async { + final first = p.join(Directory.systemTemp.path, 'zplay-downloads-one'); + final second = p.join(Directory.systemTemp.path, 'zplay-downloads-two'); + + await DownloadPathHelper.setCustomDownloadsDirectoryPath(first); + await DownloadPathHelper.setCustomDownloadsDirectoryPath(second); + await DownloadPathHelper.setCustomDownloadsDirectoryPath(first); + + final prefs = await SharedPreferences.getInstance(); + final history = + prefs.getStringList('custom_downloads_directory_history') ?? const []; + + expect(history.first, p.normalize(p.absolute(first))); + expect(history, contains(p.normalize(p.absolute(second)))); + expect(history.toSet().length, history.length); + expect(history.length, lessThanOrEqualTo(10)); + }); +} From edbb27deeaa512fd27f116c8cfc159d0b9549eb8 Mon Sep 17 00:00:00 2001 From: Browser OS Dev Date: Sat, 24 Oct 2026 10:47:16 -0300 Subject: [PATCH 19/19] style: format changed Dart files --- .../audiobooks/audiobook_player_screen.dart | 9 +- lib/pages/books/book_detail_sheet.dart | 55 +- lib/pages/books/epub_reader_page.dart | 267 +-- lib/pages/iptv/iptv_portal_browser_page.dart | 1436 +++++++++++------ lib/pages/manga/manga_reader_page.dart | 354 ++-- lib/pages/music/music_page.dart | 1033 ++++++++---- .../anime/extractors/tryembed_extractor.dart | 30 +- lib/services/anime_arabic/mega_proxy.dart | 53 +- lib/services/books/book_download_service.dart | 19 +- lib/services/books/bookracy_service.dart | 17 +- .../cloudstream/cloudstream_manager.dart | 694 +++++--- .../runtime/cloudstream_downloader.dart | 59 +- lib/services/download/download_service.dart | 251 +-- lib/services/iptv/pastesh_decryptor.dart | 34 +- .../iptv/sources/m3u_remote_source.dart | 36 +- lib/services/scraper/sites/cinejoy.dart | 128 +- lib/services/scraper/sites/fourkhdhub.dart | 103 +- lib/services/scraper/sites/hindmoviez.dart | 362 +++-- .../providers/stremio_subtitle_provider.dart | 28 +- .../providers/subtitlecat_provider.dart | 11 +- .../subtitles/subtitle_extractor.dart | 19 +- .../subtitles/subtitlecat_service.dart | 319 ++-- lib/services/updater/app_updater_service.dart | 42 +- lib/utils/download/download_path_helper.dart | 7 +- lib/widgets/updater/update_dialog.dart | 526 +++--- .../home/home_hero_carousel_reset_test.dart | 139 +- .../home/home_hero_focus_traversal_test.dart | 106 +- test/pages/home/home_hero_swipe_test.dart | 101 +- test/shell/live_tv_shortcut_test.dart | 12 +- .../page_controls_stay_focusable_test.dart | 33 +- test/shell/phone_layout_fit_test.dart | 12 +- test/shell/top_bar_traversal_test.dart | 40 +- test/shell_back_press_test.dart | 139 +- test/tv_dpad_focus_regression_test.dart | 899 ++++++----- test/tv_home_chrome_reachability_test.dart | 483 ++++-- 35 files changed, 5113 insertions(+), 2743 deletions(-) diff --git a/lib/pages/audiobooks/audiobook_player_screen.dart b/lib/pages/audiobooks/audiobook_player_screen.dart index 7a0eec65..78108735 100644 --- a/lib/pages/audiobooks/audiobook_player_screen.dart +++ b/lib/pages/audiobooks/audiobook_player_screen.dart @@ -2034,9 +2034,8 @@ class _AudiobookPlayerScreenState extends State ), ), FocusableInkWell( - onTap: () => setState( - () => _showChaptersDrawer = false, - ), + onTap: () => + setState(() => _showChaptersDrawer = false), borderRadius: ZplayRadius.fullAll, hoverColor: Colors.white.withValues( alpha: ZplayOpacity.borderDefault, @@ -2269,9 +2268,7 @@ class _ChapterListItemTile extends StatelessWidget { child: FocusableCard( onTap: onTap, builder: (context, state) { - final scale = state.pressed - ? 0.98 - : (state.hovered ? 1.015 : 1.0); + final scale = state.pressed ? 0.98 : (state.hovered ? 1.015 : 1.0); return AnimatedScale( scale: scale, diff --git a/lib/pages/books/book_detail_sheet.dart b/lib/pages/books/book_detail_sheet.dart index 9a2992ed..47dc04b6 100644 --- a/lib/pages/books/book_detail_sheet.dart +++ b/lib/pages/books/book_detail_sheet.dart @@ -16,10 +16,7 @@ import '../../widgets/common/section_header.dart'; class BookDetailSheet extends StatefulWidget { final BookResult book; - const BookDetailSheet({ - super.key, - required this.book, - }); + const BookDetailSheet({super.key, required this.book}); static Future show(BuildContext context, BookResult book) { return showModalBottomSheet( @@ -223,19 +220,32 @@ class _BookDetailSheetState extends State { placeholder: (_, __) => Container( color: tokens.surface, child: Center( - child: Icon(Icons.menu_book_rounded, color: tokens.textDisabled, size: 36), + child: Icon( + Icons.menu_book_rounded, + color: tokens.textDisabled, + size: 36, + ), ), ), errorWidget: (_, __, ___) => Container( color: tokens.surface, child: Center( - child: Icon(Icons.menu_book_rounded, color: tokens.textDisabled, size: 36), + child: Icon( + Icons.menu_book_rounded, + color: tokens.textDisabled, + size: 36, + ), ), - )) + ), + ) : Container( color: tokens.surface, child: Center( - child: Icon(Icons.menu_book_rounded, color: tokens.textDisabled, size: 36), + child: Icon( + Icons.menu_book_rounded, + color: tokens.textDisabled, + size: 36, + ), ), ), ), @@ -250,7 +260,9 @@ class _BookDetailSheetState extends State { children: [ Text( book.displayTitle, - style: ZplayType.title.toStyle(color: tokens.textPrimary), + style: ZplayType.title.toStyle( + color: tokens.textPrimary, + ), maxLines: 3, overflow: TextOverflow.ellipsis, ), @@ -278,8 +290,9 @@ class _BookDetailSheetState extends State { if (book.year.isNotEmpty) book.year, if (book.bookLang.isNotEmpty) book.bookLang, ].join(' · '), - style: ZplayType.label - .toStyle(color: tokens.textSecondary), + style: ZplayType.label.toStyle( + color: tokens.textSecondary, + ), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -341,8 +354,8 @@ class _BookDetailSheetState extends State { label: _isDownloading ? 'Downloading ${(_downloadProgress * 100).round()}%...' : hasProgress - ? 'Resume Reading' - : 'Read Now', + ? 'Resume Reading' + : 'Read Now', icon: hasProgress ? Icons.play_arrow_rounded : Icons.auto_stories_rounded, @@ -362,7 +375,9 @@ class _BookDetailSheetState extends State { Navigator.pop(context); Navigator.push( context, - MaterialPageRoute(builder: (_) => const GenerateAudiobookScreen()), + MaterialPageRoute( + builder: (_) => const GenerateAudiobookScreen(), + ), ); }, ), @@ -393,7 +408,9 @@ class _BookDetailSheetState extends State { // header bakes a 16 dp inset of its own - the translate // cancels it, the same way the details pages do, so the // heading lines up with the cover above it. - if (book.publisher.isNotEmpty || book.isbn.isNotEmpty || book.series.isNotEmpty) ...[ + if (book.publisher.isNotEmpty || + book.isbn.isNotEmpty || + book.series.isNotEmpty) ...[ Transform.translate( offset: const Offset(-ZplaySpacing.s16, 0), child: const SectionHeader(title: 'Information'), @@ -403,11 +420,13 @@ class _BookDetailSheetState extends State { _buildInfoRow('Publisher', book.publisher), if (book.series.isNotEmpty) _buildInfoRow('Series', book.series), - if (book.isbn.isNotEmpty) - _buildInfoRow('ISBN', book.isbn), + if (book.isbn.isNotEmpty) _buildInfoRow('ISBN', book.isbn), if (book.bookLang.isNotEmpty) _buildInfoRow('Language', book.bookLang), - _buildInfoRow('File Format', book.bookFiletype.toUpperCase()), + _buildInfoRow( + 'File Format', + book.bookFiletype.toUpperCase(), + ), if (book.bookSize.isNotEmpty) _buildInfoRow('Size', book.bookSize), const SizedBox(height: ZplaySpacing.s16), diff --git a/lib/pages/books/epub_reader_page.dart b/lib/pages/books/epub_reader_page.dart index 8251d9f8..9e8b7541 100644 --- a/lib/pages/books/epub_reader_page.dart +++ b/lib/pages/books/epub_reader_page.dart @@ -176,8 +176,12 @@ class _EpubReaderPageState extends State { void _saveProgress() { if (_bookData == null) return; final total = _bookData!.totalChapters; - final percent = total > 0 ? ((_currentChapterIndex + 1) / total).clamp(0.0, 1.0) : 0.0; - final offset = _scrollController.hasClients ? _scrollController.offset : 0.0; + final percent = total > 0 + ? ((_currentChapterIndex + 1) / total).clamp(0.0, 1.0) + : 0.0; + final offset = _scrollController.hasClients + ? _scrollController.offset + : 0.0; final progress = ReadingProgress( md5: widget.book.md5, @@ -185,7 +189,9 @@ class _EpubReaderPageState extends State { author: widget.book.displayAuthor, coverUrl: widget.book.coverUrl, filePath: widget.file.path, - fileType: widget.book.bookFiletype.isNotEmpty ? widget.book.bookFiletype : 'epub', + fileType: widget.book.bookFiletype.isNotEmpty + ? widget.book.bookFiletype + : 'epub', chapterIndex: _currentChapterIndex, scrollOffset: offset, progressPercent: percent, @@ -199,7 +205,10 @@ class _EpubReaderPageState extends State { } Uint8List? _getComicPageImage() { - if (_bookData == null || _currentChapterIndex >= _bookData!.chapters.length) return null; + if (_bookData == null || + _currentChapterIndex >= _bookData!.chapters.length) { + return null; + } final ch = _bookData!.chapters[_currentChapterIndex]; final key = 'page_$_currentChapterIndex.jpg'; @@ -208,7 +217,10 @@ class _EpubReaderPageState extends State { final chImg = _bookData!.resolveImage(ch.href, ch.href); if (chImg != null) return chImg; - final imgMatch = RegExp(r']+(?:src|href)="([^"]+)"', caseSensitive: false).firstMatch(ch.htmlContent); + final imgMatch = RegExp( + r']+(?:src|href)="([^"]+)"', + caseSensitive: false, + ).firstMatch(ch.htmlContent); if (imgMatch != null) { final src = imgMatch.group(1)!; final resolved = _bookData!.resolveImage(src, ch.href); @@ -225,9 +237,15 @@ class _EpubReaderPageState extends State { } String _getSamplePreviewText() { - if (_bookData == null || _currentChapterIndex >= _bookData!.chapters.length) return ''; + if (_bookData == null || + _currentChapterIndex >= _bookData!.chapters.length) { + return ''; + } final ch = _bookData!.chapters[_currentChapterIndex]; - final plain = ch.htmlContent.replaceAll(RegExp(r'<[^>]*>'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim(); + final plain = ch.htmlContent + .replaceAll(RegExp(r'<[^>]*>'), ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); if (plain.length > 200) { return '${plain.substring(0, 195)}...'; } @@ -271,13 +289,19 @@ class _EpubReaderPageState extends State { valueListenable: ReaderSettings.settingsNotifier, builder: (context, settings, _) { final comicImage = _getComicPageImage(); - final isComicMode = comicImage != null && + final isComicMode = + comicImage != null && (widget.book.isComic || _bookData!.chapters[_currentChapterIndex].wordCount == 0 || - _bookData!.chapters[_currentChapterIndex].htmlContent.contains(' { final total = _bookData?.totalChapters ?? 1; final current = _currentChapterIndex + 1; - final overallProgress = total > 0 ? (current / total).clamp(0.0, 1.0) : 0.0; + final overallProgress = total > 0 + ? (current / total).clamp(0.0, 1.0) + : 0.0; return Focus( focusNode: _readerFocusNode, @@ -302,7 +328,9 @@ class _EpubReaderPageState extends State { onKeyEvent: _handleKeyEvent, child: Scaffold( key: _scaffoldKey, - backgroundColor: isComicMode ? ReaderTokens.bg : settings.backgroundColor, + backgroundColor: isComicMode + ? ReaderTokens.bg + : settings.backgroundColor, drawer: _buildTocDrawer(), body: MouseRegion( onHover: (_) => _onUserActivity(), @@ -314,45 +342,51 @@ class _EpubReaderPageState extends State { child: _loading ? _buildSkeletonLoading(settings) : _error != null - ? _buildErrorState(settings) - : _bookData == null || _bookData!.chapters.isEmpty - ? _buildEmptyState(settings) - : isComicMode - ? ComicReaderView( - imageBytes: comicImage, - pageTitle: _bookData!.chapters[_currentChapterIndex].title, - onNextPage: _currentChapterIndex < _bookData!.chapters.length - 1 - ? () => _goToChapter(_currentChapterIndex + 1) - : null, - onPrevPage: _currentChapterIndex > 0 - ? () => _goToChapter(_currentChapterIndex - 1) - : null, - onToggleControls: _toggleChrome, - showControls: _showChrome, - ) - : GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: _toggleChrome, - child: Align( - alignment: Alignment.topCenter, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 780), - child: SingleChildScrollView( - controller: _scrollController, - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.only( - top: 76, - bottom: 96, - ), - child: EpubContentView( - book: _bookData!, - chapter: _bookData!.chapters[_currentChapterIndex], - settings: settings, - ), - ), - ), - ), - ), + ? _buildErrorState(settings) + : _bookData == null || _bookData!.chapters.isEmpty + ? _buildEmptyState(settings) + : isComicMode + ? ComicReaderView( + imageBytes: comicImage, + pageTitle: + _bookData!.chapters[_currentChapterIndex].title, + onNextPage: + _currentChapterIndex < + _bookData!.chapters.length - 1 + ? () => _goToChapter(_currentChapterIndex + 1) + : null, + onPrevPage: _currentChapterIndex > 0 + ? () => _goToChapter(_currentChapterIndex - 1) + : null, + onToggleControls: _toggleChrome, + showControls: _showChrome, + ) + : GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _toggleChrome, + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 780, + ), + child: SingleChildScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.only( + top: 76, + bottom: 96, + ), + child: EpubContentView( + book: _bookData!, + chapter: _bookData! + .chapters[_currentChapterIndex], + settings: settings, + ), + ), + ), + ), + ), ), // ── In-App Brightness Dimmer Layer ── @@ -374,11 +408,15 @@ class _EpubReaderPageState extends State { right: 0, child: AnimatedSlide( offset: _showChrome ? Offset.zero : const Offset(0, -0.2), - duration: reduceMotion ? Duration.zero : ReaderTokens.motionChrome, + duration: reduceMotion + ? Duration.zero + : ReaderTokens.motionChrome, curve: ReaderTokens.curveChrome, child: AnimatedOpacity( opacity: _showChrome ? 1.0 : 0.0, - duration: reduceMotion ? Duration.zero : ReaderTokens.motionChrome, + duration: reduceMotion + ? Duration.zero + : ReaderTokens.motionChrome, curve: ReaderTokens.curveChrome, child: IgnorePointer( ignoring: !_showChrome, @@ -405,11 +443,15 @@ class _EpubReaderPageState extends State { right: 0, child: AnimatedSlide( offset: _showChrome ? Offset.zero : const Offset(0, 0.2), - duration: reduceMotion ? Duration.zero : ReaderTokens.motionChrome, + duration: reduceMotion + ? Duration.zero + : ReaderTokens.motionChrome, curve: ReaderTokens.curveChrome, child: AnimatedOpacity( opacity: _showChrome ? 1.0 : 0.0, - duration: reduceMotion ? Duration.zero : ReaderTokens.motionChrome, + duration: reduceMotion + ? Duration.zero + : ReaderTokens.motionChrome, curve: ReaderTokens.curveChrome, child: IgnorePointer( ignoring: !_showChrome, @@ -430,52 +472,57 @@ class _EpubReaderPageState extends State { ), // ── Custom Scroll Track (Desktop Only) ── - if (!isComicMode && !_loading && _error == null && _bookData != null && MediaQuery.sizeOf(context).width > 800) + if (!isComicMode && + !_loading && + _error == null && + _bookData != null && + MediaQuery.sizeOf(context).width > 800) Positioned( right: 24, bottom: 40, child: CustomScrollTrack(controller: _scrollController), ), - // ── Hairline Progress Bar Pinned to Absolute Bottom (Always Visible) ── - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Stack( - children: [ - // 2px track in 12% opacity - Container( - height: 2.0, - color: settings.textColor.withValues(alpha: 0.12), - ), - // 2px active indicator - FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: overallProgress, - child: Container( + // ── Hairline Progress Bar Pinned to Absolute Bottom (Always Visible) ── + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Stack( + children: [ + // 2px track in 12% opacity + Container( height: 2.0, - color: settings.accentColor, + color: settings.textColor.withValues(alpha: 0.12), ), - ), - ], + // 2px active indicator + FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: overallProgress, + child: Container( + height: 2.0, + color: settings.accentColor, + ), + ), + ], + ), ), - ), - ], + ], + ), ), ), - ), - ); - }, - ); -} + ); + }, + ); + } // ────────────────────────────────────────────────────────────────────────── // TOP CHROME (56px Height + 24px Gradient Scrim) // ────────────────────────────────────────────────────────────────────────── Widget _buildTopChrome(ReaderSettingsData settings, bool isComicMode) { - final chapterTitle = _bookData != null && _currentChapterIndex < _bookData!.chapters.length + final chapterTitle = + _bookData != null && _currentChapterIndex < _bookData!.chapters.length ? _bookData!.chapters[_currentChapterIndex].title : widget.book.displayTitle; @@ -585,7 +632,9 @@ class _EpubReaderPageState extends State { // ────────────────────────────────────────────────────────────────────────── Widget _buildBottomChrome() { - if (_bookData == null || _bookData!.totalChapters == 0) return const SizedBox.shrink(); + if (_bookData == null || _bookData!.totalChapters == 0) { + return const SizedBox.shrink(); + } final total = _bookData!.totalChapters; final current = _currentChapterIndex + 1; @@ -621,11 +670,15 @@ class _EpubReaderPageState extends State { size: 26, ), tooltip: 'Previous Chapter (Left Arrow)', - onPressed: _currentChapterIndex > 0 ? () => _goToChapter(_currentChapterIndex - 1) : null, + onPressed: _currentChapterIndex > 0 + ? () => _goToChapter(_currentChapterIndex - 1) + : null, ), Expanded( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: ReaderTokens.space8), + padding: const EdgeInsets.symmetric( + horizontal: ReaderTokens.space8, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -652,7 +705,9 @@ class _EpubReaderPageState extends State { size: 26, ), tooltip: 'Next Chapter (Right Arrow)', - onPressed: _currentChapterIndex < total - 1 ? () => _goToChapter(_currentChapterIndex + 1) : null, + onPressed: _currentChapterIndex < total - 1 + ? () => _goToChapter(_currentChapterIndex + 1) + : null, ), ], ), @@ -677,7 +732,9 @@ class _EpubReaderPageState extends State { height: 28, width: 220, decoration: BoxDecoration( - color: settings.textColor.withValues(alpha: ZplayOpacity.borderDefault), + color: settings.textColor.withValues( + alpha: ZplayOpacity.borderDefault, + ), borderRadius: ReaderTokens.rounded8, ), ), @@ -687,7 +744,9 @@ class _EpubReaderPageState extends State { height: 16, width: double.infinity, decoration: BoxDecoration( - color: settings.textColor.withValues(alpha: ZplayOpacity.borderSubtle), + color: settings.textColor.withValues( + alpha: ZplayOpacity.borderSubtle, + ), borderRadius: ReaderTokens.rounded4, ), ), @@ -697,7 +756,9 @@ class _EpubReaderPageState extends State { height: 16, width: 180, decoration: BoxDecoration( - color: settings.textColor.withValues(alpha: ZplayOpacity.borderSubtle), + color: settings.textColor.withValues( + alpha: ZplayOpacity.borderSubtle, + ), borderRadius: ReaderTokens.rounded4, ), ), @@ -715,7 +776,11 @@ class _EpubReaderPageState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.auto_stories_outlined, color: ReaderTokens.danger, size: 48), + Icon( + Icons.auto_stories_outlined, + color: ReaderTokens.danger, + size: 48, + ), const SizedBox(height: ReaderTokens.space16), Text( _error ?? 'Unable to open book', @@ -731,10 +796,7 @@ class _EpubReaderPageState extends State { child: const Text('Back to Library'), ), const SizedBox(width: ReaderTokens.space12), - FilledButton( - onPressed: _loadBook, - child: const Text('Retry'), - ), + FilledButton(onPressed: _loadBook, child: const Text('Retry')), ], ), ], @@ -750,13 +812,15 @@ class _EpubReaderPageState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.library_books_rounded, color: settings.secondaryTextColor, size: 48), + Icon( + Icons.library_books_rounded, + color: settings.secondaryTextColor, + size: 48, + ), const SizedBox(height: ReaderTokens.space16), Text( 'No readable chapters found.', - style: ZplayType.body.toStyle( - color: settings.secondaryTextColor, - ), + style: ZplayType.body.toStyle(color: settings.secondaryTextColor), ), ], ), @@ -814,7 +878,8 @@ class _EpubReaderPageState extends State { itemCount: toc.length, itemBuilder: (context, idx) { final item = toc[idx]; - final isSelected = item.chapterIndex == _currentChapterIndex; + final isSelected = + item.chapterIndex == _currentChapterIndex; return ListTile( contentPadding: const EdgeInsets.symmetric( diff --git a/lib/pages/iptv/iptv_portal_browser_page.dart b/lib/pages/iptv/iptv_portal_browser_page.dart index 6c52e553..73044c29 100644 --- a/lib/pages/iptv/iptv_portal_browser_page.dart +++ b/lib/pages/iptv/iptv_portal_browser_page.dart @@ -92,7 +92,8 @@ class _IptvPortalBrowserPageState extends State { @override void initState() { super.initState(); - final name = widget.portal?.name ?? widget.m3uPlaylist?.name ?? 'IPTV Portal'; + final name = + widget.portal?.name ?? widget.m3uPlaylist?.name ?? 'IPTV Portal'; DiscordRpcService.instance.setWatchingLiveTv(channelName: 'Portal: $name'); _categoryScrollController.addListener(_updateCategoryScrollState); _contentScrollController.addListener(_updateContentScrollState); @@ -168,11 +169,17 @@ class _IptvPortalBrowserPageState extends State { const SizedBox(width: ZplaySpacing.s8), Text( 'Customize Portal Browser', - style: ZplayType.title.toStyle(color: tokens.textPrimary), + style: ZplayType.title.toStyle( + color: tokens.textPrimary, + ), ), const Spacer(), IconButton( - icon: Icon(Icons.close_rounded, color: tokens.textMuted, size: 20), + icon: Icon( + Icons.close_rounded, + color: tokens.textMuted, + size: 20, + ), onPressed: () => Navigator.pop(ctx), ), ], @@ -205,7 +212,9 @@ class _IptvPortalBrowserPageState extends State { ValueListenableBuilder( valueListenable: IptvSettings.browserLayout, builder: (context, layout, _) { - if (layout != PortalBrowserLayout.grid) return const SizedBox.shrink(); + if (layout != PortalBrowserLayout.grid) { + return const SizedBox.shrink(); + } return ValueListenableBuilder( valueListenable: IptvSettings.browserGridColumns, builder: (context, cols, _) { @@ -213,10 +222,21 @@ class _IptvPortalBrowserPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: + MainAxisAlignment.spaceBetween, children: [ - Text('Grid Stream Columns', style: ZplayType.label.toStyle(color: tokens.textPrimary)), - Text('$cols Cols', style: ZplayType.label.toStyle(color: tokens.accent)), + Text( + 'Grid Stream Columns', + style: ZplayType.label.toStyle( + color: tokens.textPrimary, + ), + ), + Text( + '$cols Cols', + style: ZplayType.label.toStyle( + color: tokens.accent, + ), + ), ], ), SliderTheme( @@ -231,7 +251,10 @@ class _IptvPortalBrowserPageState extends State { min: 2, max: 6, divisions: 4, - onChanged: (val) => IptvSettings.setBrowserGridColumns(val.round()), + onChanged: (val) => + IptvSettings.setBrowserGridColumns( + val.round(), + ), ), ), const SizedBox(height: 8), @@ -247,10 +270,16 @@ class _IptvPortalBrowserPageState extends State { builder: (context, showLogos, _) { return SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, - title: Text('Show Channel Stream Logos', style: ZplayType.body.toStyle(color: tokens.textPrimary)), + title: Text( + 'Show Channel Stream Logos', + style: ZplayType.body.toStyle( + color: tokens.textPrimary, + ), + ), value: showLogos, activeColor: tokens.accent, - onChanged: (val) => IptvSettings.setShowStreamLogos(val), + onChanged: (val) => + IptvSettings.setShowStreamLogos(val), ); }, ), @@ -260,7 +289,12 @@ class _IptvPortalBrowserPageState extends State { builder: (context, showEpg, _) { return SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, - title: Text('Show EPG "Now Playing" Snippet', style: ZplayType.body.toStyle(color: tokens.textPrimary)), + title: Text( + 'Show EPG "Now Playing" Snippet', + style: ZplayType.body.toStyle( + color: tokens.textPrimary, + ), + ), value: showEpg, activeColor: tokens.accent, onChanged: (val) => IptvSettings.setShowEpgSnippet(val), @@ -273,10 +307,16 @@ class _IptvPortalBrowserPageState extends State { builder: (context, showCount, _) { return SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, - title: Text('Show Category Stream Counts', style: ZplayType.body.toStyle(color: tokens.textPrimary)), + title: Text( + 'Show Category Stream Counts', + style: ZplayType.body.toStyle( + color: tokens.textPrimary, + ), + ), value: showCount, activeColor: tokens.accent, - onChanged: (val) => IptvSettings.setShowCategoryCount(val), + onChanged: (val) => + IptvSettings.setShowCategoryCount(val), ); }, ), @@ -292,7 +332,8 @@ class _IptvPortalBrowserPageState extends State { void _updateCategoryScrollState() { if (!_categoryScrollController.hasClients) return; final canUp = _categoryScrollController.position.pixels > 10; - final canDown = _categoryScrollController.position.pixels < + final canDown = + _categoryScrollController.position.pixels < _categoryScrollController.position.maxScrollExtent - 10; if (canUp != _canScrollCatUp || canDown != _canScrollCatDown) { setState(() { @@ -305,7 +346,8 @@ class _IptvPortalBrowserPageState extends State { void _updateContentScrollState() { if (!_contentScrollController.hasClients) return; final canUp = _contentScrollController.position.pixels > 10; - final canDown = _contentScrollController.position.pixels < + final canDown = + _contentScrollController.position.pixels < _contentScrollController.position.maxScrollExtent - 10; if (canUp != _canScrollContentUp || canDown != _canScrollContentDown) { setState(() { @@ -317,8 +359,10 @@ class _IptvPortalBrowserPageState extends State { void _scrollCategories(double delta) { if (!_categoryScrollController.hasClients) return; - final target = (_categoryScrollController.position.pixels + delta) - .clamp(0.0, _categoryScrollController.position.maxScrollExtent); + final target = (_categoryScrollController.position.pixels + delta).clamp( + 0.0, + _categoryScrollController.position.maxScrollExtent, + ); _categoryScrollController.animateTo( target, duration: const Duration(milliseconds: 300), @@ -328,8 +372,10 @@ class _IptvPortalBrowserPageState extends State { void _scrollContent(double delta) { if (!_contentScrollController.hasClients) return; - final target = (_contentScrollController.position.pixels + delta) - .clamp(0.0, _contentScrollController.position.maxScrollExtent); + final target = (_contentScrollController.position.pixels + delta).clamp( + 0.0, + _contentScrollController.position.maxScrollExtent, + ); _contentScrollController.animateTo( target, duration: const Duration(milliseconds: 300), @@ -404,14 +450,16 @@ class _IptvPortalBrowserPageState extends State { .toList(); final streams = pl.channels - .map((c) => IptvStream( - streamId: c.url, - name: c.name, - icon: c.logo, - categoryId: c.group.isNotEmpty ? c.group : 'General', - containerExt: 'm3u8', - kind: 'live', - )) + .map( + (c) => IptvStream( + streamId: c.url, + name: c.name, + icon: c.logo, + categoryId: c.group.isNotEmpty ? c.group : 'General', + containerExt: 'm3u8', + kind: 'live', + ), + ) .toList(); if (!mounted) return; @@ -452,7 +500,11 @@ class _IptvPortalBrowserPageState extends State { } Future _startAliveCheck() async { - if (widget.portal == null || _activeSection != IptvSection.live || _isCheckingAlive) return; + if (widget.portal == null || + _activeSection != IptvSection.live || + _isCheckingAlive) { + return; + } final p = widget.portal!.portal; final filtered = _filteredStreams(); @@ -524,7 +576,9 @@ class _IptvPortalBrowserPageState extends State { int _countForCategory(String catId) { if (catId == favoritesCategoryId) { - return _allStreams.where((s) => _favoriteStreamIds.contains(s.streamId)).length; + return _allStreams + .where((s) => _favoriteStreamIds.contains(s.streamId)) + .length; } if (catId.isEmpty) return _allStreams.length; return _allStreams.where((s) => s.categoryId == catId).length; @@ -537,8 +591,10 @@ class _IptvPortalBrowserPageState extends State { debugPrint('[IPTV] "${stream.name}" has no playable stream URL'); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('"${stream.name}" has no playable stream. ' - 'Reconnect the portal or remove it and add another.'), + content: Text( + '"${stream.name}" has no playable stream. ' + 'Reconnect the portal or remove it and add another.', + ), ), ); } @@ -558,12 +614,19 @@ class _IptvPortalBrowserPageState extends State { } final isLive = _activeSection == IptvSection.live; final currentList = _filteredStreams(); - final clickedIndex = currentList.indexWhere((s) => s.streamId == stream.streamId); + final clickedIndex = currentList.indexWhere( + (s) => s.streamId == stream.streamId, + ); final initialIndex = clickedIndex >= 0 ? clickedIndex : 0; final currentCat = _categories.firstWhere( (c) => c.id == _selectedCategoryId, - orElse: () => IptvCategory(id: '', name: isLive ? 'Live Channels' : (_activeSection == IptvSection.vod ? 'Movies' : 'Series')), + orElse: () => IptvCategory( + id: '', + name: isLive + ? 'Live Channels' + : (_activeSection == IptvSection.vod ? 'Movies' : 'Series'), + ), ); if (widget.portal != null) { @@ -571,11 +634,13 @@ class _IptvPortalBrowserPageState extends State { // A hit with no URL can only fail silently in the player, so it is // dropped here rather than handed over. final hits = currentList - .map((s) => ChannelHit( - portal: p, - stream: s, - streamUrl: IptvClient.streamUrl(p.portal, s), - )) + .map( + (s) => ChannelHit( + portal: p, + stream: s, + streamUrl: IptvClient.streamUrl(p.portal, s), + ), + ) .where((h) => h.streamUrl.isNotEmpty) .toList(); if (hits.isEmpty) { @@ -586,7 +651,9 @@ class _IptvPortalBrowserPageState extends State { final ch = HardcodedChannel( id: stream.streamId, name: stream.name, - short: isLive ? 'LIVE' : (_activeSection == IptvSection.vod ? 'VOD' : 'SERIES'), + short: isLive + ? 'LIVE' + : (_activeSection == IptvSection.vod ? 'VOD' : 'SERIES'), category: currentCat.name, keywords: [stream.name], gradient: [context.tokens.accent, context.tokens.info], @@ -613,17 +680,26 @@ class _IptvPortalBrowserPageState extends State { ), ); } else if (widget.m3uPlaylist != null) { - final hits = currentList.map((s) => ChannelHit( - portal: VerifiedPortal( - portal: IptvPortal(url: s.streamId, username: '', password: '', source: 'M3U'), - name: widget.m3uPlaylist!.name, - expiry: '', - maxConnections: '1', - activeConnections: '0', - ), - stream: s, - streamUrl: s.streamId, - )).toList(); + final hits = currentList + .map( + (s) => ChannelHit( + portal: VerifiedPortal( + portal: IptvPortal( + url: s.streamId, + username: '', + password: '', + source: 'M3U', + ), + name: widget.m3uPlaylist!.name, + expiry: '', + maxConnections: '1', + activeConnections: '0', + ), + stream: s, + streamUrl: s.streamId, + ), + ) + .toList(); final ch = HardcodedChannel( id: stream.streamId, @@ -644,7 +720,12 @@ class _IptvPortalBrowserPageState extends State { : [ ChannelHit( portal: VerifiedPortal( - portal: IptvPortal(url: stream.streamId, username: '', password: '', source: 'M3U'), + portal: IptvPortal( + url: stream.streamId, + username: '', + password: '', + source: 'M3U', + ), name: widget.m3uPlaylist!.name, expiry: '', maxConnections: '1', @@ -698,12 +779,12 @@ class _IptvPortalBrowserPageState extends State { context: context, backgroundColor: Colors.transparent, isScrollControlled: true, - builder: (ctx) => _SeriesEpisodesSheet( - portal: widget.portal!, - series: series, - ), + builder: (ctx) => + _SeriesEpisodesSheet(portal: widget.portal!, series: series), ); - } void _showMobileCategorySheet(BuildContext context) { + } + + void _showMobileCategorySheet(BuildContext context) { showModalBottomSheet( context: context, isScrollControlled: true, @@ -724,48 +805,77 @@ class _IptvPortalBrowserPageState extends State { children: [ Center( child: Container( - margin: const EdgeInsets.only(top: ZplaySpacing.s8, bottom: ZplaySpacing.s12), + margin: const EdgeInsets.only( + top: ZplaySpacing.s8, + bottom: ZplaySpacing.s12, + ), width: 40, height: 4, decoration: BoxDecoration( - color: tokens.textPrimary.withValues(alpha: ZplayOpacity.overlayHover), + color: tokens.textPrimary.withValues( + alpha: ZplayOpacity.overlayHover, + ), borderRadius: BorderRadius.circular(2), ), ), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: ZplaySpacing.s16), + padding: const EdgeInsets.symmetric( + horizontal: ZplaySpacing.s16, + ), child: Row( children: [ - Icon(Icons.folder_rounded, color: tokens.accent, size: 20), + Icon( + Icons.folder_rounded, + color: tokens.accent, + size: 20, + ), const SizedBox(width: ZplaySpacing.s8), Text( 'Select Category', - style: ZplayType.title.toStyle(color: tokens.textPrimary), + style: ZplayType.title.toStyle( + color: tokens.textPrimary, + ), ), const Spacer(), Text( '${_categories.length} total', - style: ZplayType.bodySmall.toStyle(color: tokens.textMuted), + style: ZplayType.bodySmall.toStyle( + color: tokens.textMuted, + ), ), ], ), ), const SizedBox(height: ZplaySpacing.s12), Padding( - padding: const EdgeInsets.symmetric(horizontal: ZplaySpacing.s16), + padding: const EdgeInsets.symmetric( + horizontal: ZplaySpacing.s16, + ), child: SizedBox( height: 40, child: TextField( controller: _catSearchCtrl, - style: ZplayType.bodySmall.toStyle(color: tokens.textPrimary), + style: ZplayType.bodySmall.toStyle( + color: tokens.textPrimary, + ), decoration: InputDecoration( hintText: 'Filter categories…', - hintStyle: ZplayType.bodySmall.toStyle(color: tokens.textMuted), - prefixIcon: Icon(Icons.search_rounded, color: tokens.textMuted, size: 18), + hintStyle: ZplayType.bodySmall.toStyle( + color: tokens.textMuted, + ), + prefixIcon: Icon( + Icons.search_rounded, + color: tokens.textMuted, + size: 18, + ), suffixIcon: _catSearchQuery.isNotEmpty ? IconButton( - icon: Icon(Icons.close_rounded, color: tokens.textMuted, size: 16), + icon: Icon( + Icons.close_rounded, + color: tokens.textMuted, + size: 16, + ), onPressed: () { _catSearchCtrl.clear(); setState(() => _catSearchQuery = ''); @@ -779,7 +889,9 @@ class _IptvPortalBrowserPageState extends State { borderRadius: ZplayRadius.smAll, borderSide: BorderSide.none, ), - contentPadding: const EdgeInsets.symmetric(vertical: ZplaySpacing.s8), + contentPadding: const EdgeInsets.symmetric( + vertical: ZplaySpacing.s8, + ), ), onChanged: (v) { setState(() => _catSearchQuery = v); @@ -793,7 +905,10 @@ class _IptvPortalBrowserPageState extends State { child: ListView.builder( itemCount: cats.length, itemExtent: 50.0, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), itemBuilder: (ctx, i) { final cat = cats[i]; final isSelected = _selectedCategoryId == cat.id; @@ -849,14 +964,22 @@ class _IptvPortalBrowserPageState extends State { child: Row( children: [ IconButton( - icon: Icon(Icons.arrow_back_rounded, color: tokens.textPrimary, size: 22), + icon: Icon( + Icons.arrow_back_rounded, + color: tokens.textPrimary, + size: 22, + ), tooltip: 'Back', onPressed: () => Navigator.pop(context), ), const SizedBox(width: ZplaySpacing.s8), // Portal emblem — bare accent glyph, no boxed chrome. - Icon(Icons.settings_input_antenna_rounded, color: tokens.accent, size: 20), + Icon( + Icons.settings_input_antenna_rounded, + color: tokens.accent, + size: 20, + ), const SizedBox(width: ZplaySpacing.s12), Expanded( @@ -868,7 +991,9 @@ class _IptvPortalBrowserPageState extends State { title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: ZplayType.title.toStyle(color: tokens.textPrimary), + style: ZplayType.title.toStyle( + color: tokens.textPrimary, + ), ), if (widget.portal != null) ...[ const SizedBox(height: ZplaySpacing.s2), @@ -876,14 +1001,25 @@ class _IptvPortalBrowserPageState extends State { children: [ Text( 'Expiry: ${widget.portal!.expiry}', - style: ZplayType.caption.toStyle(color: tokens.textMuted), + style: ZplayType.caption.toStyle( + color: tokens.textMuted, + ), ), const SizedBox(width: ZplaySpacing.s12), - Container(width: 3, height: 3, decoration: BoxDecoration(color: tokens.textMuted, shape: BoxShape.circle)), + Container( + width: 3, + height: 3, + decoration: BoxDecoration( + color: tokens.textMuted, + shape: BoxShape.circle, + ), + ), const SizedBox(width: ZplaySpacing.s12), Text( 'Connections: ${widget.portal!.activeConnections}/${widget.portal!.maxConnections}', - style: ZplayType.caption.toStyle(color: tokens.textMuted), + style: ZplayType.caption.toStyle( + color: tokens.textMuted, + ), ), ], ), @@ -901,11 +1037,23 @@ class _IptvPortalBrowserPageState extends State { Row( mainAxisSize: MainAxisSize.min, children: [ - _buildSectionTab('Live TV', Icons.live_tv_rounded, IptvSection.live), + _buildSectionTab( + 'Live TV', + Icons.live_tv_rounded, + IptvSection.live, + ), const SizedBox(width: ZplaySpacing.s4), - _buildSectionTab('Movies', Icons.movie_rounded, IptvSection.vod), + _buildSectionTab( + 'Movies', + Icons.movie_rounded, + IptvSection.vod, + ), const SizedBox(width: ZplaySpacing.s4), - _buildSectionTab('TV Series', Icons.tv_rounded, IptvSection.series), + _buildSectionTab( + 'TV Series', + Icons.tv_rounded, + IptvSection.series, + ), ], ), const SizedBox(width: ZplaySpacing.s16), @@ -917,14 +1065,26 @@ class _IptvPortalBrowserPageState extends State { height: 40, child: TextField( controller: _searchCtrl, - style: ZplayType.body.toStyle(color: tokens.textPrimary), + style: ZplayType.body.toStyle( + color: tokens.textPrimary, + ), decoration: InputDecoration( hintText: 'Search channels…', - hintStyle: ZplayType.bodySmall.toStyle(color: tokens.textMuted), - prefixIcon: Icon(Icons.search_rounded, color: tokens.accent, size: 18), + hintStyle: ZplayType.bodySmall.toStyle( + color: tokens.textMuted, + ), + prefixIcon: Icon( + Icons.search_rounded, + color: tokens.accent, + size: 18, + ), suffixIcon: _searchQuery.isNotEmpty ? IconButton( - icon: Icon(Icons.close_rounded, color: tokens.textMuted, size: 16), + icon: Icon( + Icons.close_rounded, + color: tokens.textMuted, + size: 16, + ), onPressed: () { _searchDebounce?.cancel(); _searchCtrl.clear(); @@ -934,7 +1094,10 @@ class _IptvPortalBrowserPageState extends State { : null, filled: true, fillColor: tokens.surface, - contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: ZplaySpacing.s12), + contentPadding: const EdgeInsets.symmetric( + vertical: 0, + horizontal: ZplaySpacing.s12, + ), border: const OutlineInputBorder( borderRadius: ZplayRadius.smAll, borderSide: BorderSide.none, @@ -945,358 +1108,444 @@ class _IptvPortalBrowserPageState extends State { ), // Alive Sniffer Action - if (_activeSection == IptvSection.live && widget.portal != null) ...[ + if (_activeSection == IptvSection.live && + widget.portal != null) ...[ const SizedBox(width: ZplaySpacing.s12), ElevatedButton.icon( style: ElevatedButton.styleFrom( - backgroundColor: _isCheckingAlive ? tokens.danger : tokens.accent, + backgroundColor: _isCheckingAlive + ? tokens.danger + : tokens.accent, foregroundColor: tokens.onAccent, - padding: const EdgeInsets.symmetric(horizontal: ZplaySpacing.s12, vertical: ZplaySpacing.s8), - shape: const RoundedRectangleBorder(borderRadius: ZplayRadius.smAll), + padding: const EdgeInsets.symmetric( + horizontal: ZplaySpacing.s12, + vertical: ZplaySpacing.s8, + ), + shape: const RoundedRectangleBorder( + borderRadius: ZplayRadius.smAll, + ), ), icon: _isCheckingAlive ? const SizedBox( width: 14, height: 14, - child: CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator( + strokeWidth: 2, + ), ) : const Icon(Icons.speed_rounded, size: 16), label: Text( - _isCheckingAlive ? 'Stop ($_aliveChecked/$_aliveTotal)' : 'Check Health', - style: ZplayType.label.toStyle(color: tokens.onAccent), + _isCheckingAlive + ? 'Stop ($_aliveChecked/$_aliveTotal)' + : 'Check Health', + style: ZplayType.label.toStyle( + color: tokens.onAccent, + ), ), - onPressed: _isCheckingAlive ? () => setState(() => _cancelAlive = true) : _startAliveCheck, + onPressed: _isCheckingAlive + ? () => setState(() => _cancelAlive = true) + : _startAliveCheck, ), ], const SizedBox(width: ZplaySpacing.s8), IconButton( - icon: Icon(Icons.tune_rounded, color: tokens.textSecondary, size: 20), + icon: Icon( + Icons.tune_rounded, + color: tokens.textSecondary, + size: 20, + ), tooltip: 'Customize Portal Browser Layout', onPressed: () => _showBrowserCustomizer(context), ), ], ), ) - else - // ── MOBILE RESPONSIVE HEADER ── - Padding( - padding: const EdgeInsets.fromLTRB( - ZplaySpacing.s12, - ZplaySpacing.s8, - ZplaySpacing.s12, - ZplaySpacing.s8, - ), - child: Column( - children: [ - // Top Bar: Back, Portal Title, Health button - Row( - children: [ - IconButton( - icon: Icon(Icons.arrow_back_rounded, color: tokens.textPrimary, size: 22), - onPressed: () => Navigator.pop(context), - ), - const SizedBox(width: ZplaySpacing.s4), - Icon(Icons.settings_input_antenna_rounded, color: tokens.accent, size: 16), - const SizedBox(width: ZplaySpacing.s8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: ZplayType.title.toStyle(color: tokens.textPrimary), - ), - if (widget.portal != null) - Text( - 'Conn: ${widget.portal!.activeConnections}/${widget.portal!.maxConnections} • ${widget.portal!.expiry}', - style: ZplayType.caption.toStyle(color: tokens.textMuted), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - if (_activeSection == IptvSection.live && widget.portal != null) + else + // ── MOBILE RESPONSIVE HEADER ── + Padding( + padding: const EdgeInsets.fromLTRB( + ZplaySpacing.s12, + ZplaySpacing.s8, + ZplaySpacing.s12, + ZplaySpacing.s8, + ), + child: Column( + children: [ + // Top Bar: Back, Portal Title, Health button + Row( + children: [ IconButton( - tooltip: 'Check Health', - icon: _isCheckingAlive - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2, color: tokens.danger), - ) - : Icon(Icons.speed_rounded, color: tokens.accent, size: 20), - onPressed: _isCheckingAlive ? () => setState(() => _cancelAlive = true) : _startAliveCheck, - ), - IconButton( - icon: Icon(Icons.tune_rounded, color: tokens.textSecondary, size: 20), - tooltip: 'Customize', - onPressed: () => _showBrowserCustomizer(context), - ), - ], - ), - - const SizedBox(height: ZplaySpacing.s8), - - // Controls Bar: Category Selector Chip + Section Tabs - Row( - children: [ - // Mobile Category Chip — a remote can land here, so it is a - // FocusableInkWell; with no box, the accent folder icon is - // what marks it as the category control. - FocusableInkWell( - onTap: () => _showMobileCategorySheet(context), - borderRadius: ZplayRadius.smAll, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: ZplaySpacing.s8, - vertical: ZplaySpacing.s4, + icon: Icon( + Icons.arrow_back_rounded, + color: tokens.textPrimary, + size: 22, ), - child: Row( + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: ZplaySpacing.s4), + Icon( + Icons.settings_input_antenna_rounded, + color: tokens.accent, + size: 16, + ), + const SizedBox(width: ZplaySpacing.s8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.folder_rounded, color: tokens.accent, size: 15), - const SizedBox(width: ZplaySpacing.s8), - ConstrainedBox( - constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.32), - child: Text( - _selectedCategoryName(), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ZplayType.title.toStyle( + color: tokens.textPrimary, + ), + ), + if (widget.portal != null) + Text( + 'Conn: ${widget.portal!.activeConnections}/${widget.portal!.maxConnections} • ${widget.portal!.expiry}', + style: ZplayType.caption.toStyle( + color: tokens.textMuted, + ), maxLines: 1, overflow: TextOverflow.ellipsis, - style: ZplayType.label.toStyle(color: tokens.textPrimary), ), - ), - const SizedBox(width: ZplaySpacing.s4), - Icon(Icons.arrow_drop_down_rounded, color: tokens.accent, size: 18), ], ), ), - ), + if (_activeSection == IptvSection.live && + widget.portal != null) + IconButton( + tooltip: 'Check Health', + icon: _isCheckingAlive + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: tokens.danger, + ), + ) + : Icon( + Icons.speed_rounded, + color: tokens.accent, + size: 20, + ), + onPressed: _isCheckingAlive + ? () => setState(() => _cancelAlive = true) + : _startAliveCheck, + ), + IconButton( + icon: Icon( + Icons.tune_rounded, + color: tokens.textSecondary, + size: 20, + ), + tooltip: 'Customize', + onPressed: () => _showBrowserCustomizer(context), + ), + ], + ), - const SizedBox(width: ZplaySpacing.s8), + const SizedBox(height: ZplaySpacing.s8), - // Section Switchers - if (widget.portal != null) - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, + // Controls Bar: Category Selector Chip + Section Tabs + Row( + children: [ + // Mobile Category Chip — a remote can land here, so it is a + // FocusableInkWell; with no box, the accent folder icon is + // what marks it as the category control. + FocusableInkWell( + onTap: () => _showMobileCategorySheet(context), + borderRadius: ZplayRadius.smAll, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: ZplaySpacing.s8, + vertical: ZplaySpacing.s4, + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - _buildSectionTab('Live', Icons.live_tv_rounded, IptvSection.live), - const SizedBox(width: ZplaySpacing.s2), - _buildSectionTab('Movies', Icons.movie_rounded, IptvSection.vod), - const SizedBox(width: ZplaySpacing.s2), - _buildSectionTab('Series', Icons.tv_rounded, IptvSection.series), + Icon( + Icons.folder_rounded, + color: tokens.accent, + size: 15, + ), + const SizedBox(width: ZplaySpacing.s8), + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: + MediaQuery.sizeOf(context).width * + 0.32, + ), + child: Text( + _selectedCategoryName(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ZplayType.label.toStyle( + color: tokens.textPrimary, + ), + ), + ), + const SizedBox(width: ZplaySpacing.s4), + Icon( + Icons.arrow_drop_down_rounded, + color: tokens.accent, + size: 18, + ), ], ), ), ), - ], - ), - const SizedBox(height: ZplaySpacing.s8), + const SizedBox(width: ZplaySpacing.s8), - // Search Input - SizedBox( - height: 38, - child: TextField( - controller: _searchCtrl, - style: ZplayType.bodySmall.toStyle(color: tokens.textPrimary), - decoration: InputDecoration( - hintText: 'Search in this category…', - hintStyle: ZplayType.bodySmall.toStyle(color: tokens.textMuted), - prefixIcon: Icon(Icons.search_rounded, color: tokens.accent, size: 18), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - icon: Icon(Icons.close_rounded, color: tokens.textMuted, size: 16), - onPressed: () { - _searchDebounce?.cancel(); - _searchCtrl.clear(); - setState(() => _searchQuery = ''); - }, - ) - : null, - filled: true, - fillColor: tokens.surface, - border: const OutlineInputBorder( - borderRadius: ZplayRadius.smAll, - borderSide: BorderSide.none, + // Section Switchers + if (widget.portal != null) + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildSectionTab( + 'Live', + Icons.live_tv_rounded, + IptvSection.live, + ), + const SizedBox(width: ZplaySpacing.s2), + _buildSectionTab( + 'Movies', + Icons.movie_rounded, + IptvSection.vod, + ), + const SizedBox(width: ZplaySpacing.s2), + _buildSectionTab( + 'Series', + Icons.tv_rounded, + IptvSection.series, + ), + ], + ), + ), + ), + ], + ), + + const SizedBox(height: ZplaySpacing.s8), + + // Search Input + SizedBox( + height: 38, + child: TextField( + controller: _searchCtrl, + style: ZplayType.bodySmall.toStyle( + color: tokens.textPrimary, ), - contentPadding: const EdgeInsets.symmetric( - vertical: ZplaySpacing.s8, - horizontal: ZplaySpacing.s8, + decoration: InputDecoration( + hintText: 'Search in this category…', + hintStyle: ZplayType.bodySmall.toStyle( + color: tokens.textMuted, + ), + prefixIcon: Icon( + Icons.search_rounded, + color: tokens.accent, + size: 18, + ), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: Icon( + Icons.close_rounded, + color: tokens.textMuted, + size: 16, + ), + onPressed: () { + _searchDebounce?.cancel(); + _searchCtrl.clear(); + setState(() => _searchQuery = ''); + }, + ) + : null, + filled: true, + fillColor: tokens.surface, + border: const OutlineInputBorder( + borderRadius: ZplayRadius.smAll, + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + vertical: ZplaySpacing.s8, + horizontal: ZplaySpacing.s8, + ), ), + onChanged: _onSearchChanged, ), - onChanged: _onSearchChanged, ), - ), - ], + ], + ), ), - ), - // ── MAIN CONTENT (SPLIT VIEW ON DESKTOP, FULL-WIDTH ON MOBILE) ── - Expanded( - child: _isLoading - ? Center(child: CircularProgressIndicator(color: tokens.accent)) - : _errorMessage != null - ? ErrorView( - error: _errorMessage, - onRetry: _loadSectionData, - title: 'Could not load this portal', - ) - : isDesktop - ? Row( + // ── MAIN CONTENT (SPLIT VIEW ON DESKTOP, FULL-WIDTH ON MOBILE) ── + Expanded( + child: _isLoading + ? Center( + child: CircularProgressIndicator(color: tokens.accent), + ) + : _errorMessage != null + ? ErrorView( + error: _errorMessage, + onRetry: _loadSectionData, + title: 'Could not load this portal', + ) + : isDesktop + ? Row( + children: [ + // ── LEFT CATEGORIES PANEL ── + // Same canvas as the content beside it; the split is + // carried by spacing, not by a fill or a divider. + SizedBox( + width: IptvSettings.sidebarWidth.value, + child: Column( children: [ - // ── LEFT CATEGORIES PANEL ── - // Same canvas as the content beside it; the split is - // carried by spacing, not by a fill or a divider. - SizedBox( - width: IptvSettings.sidebarWidth.value, - child: Column( - children: [ - // Categories Search Filter - Padding( - padding: const EdgeInsets.fromLTRB( - ZplaySpacing.s12, - ZplaySpacing.s12, - ZplaySpacing.s12, - ZplaySpacing.s8, + // Categories Search Filter + Padding( + padding: const EdgeInsets.fromLTRB( + ZplaySpacing.s12, + ZplaySpacing.s12, + ZplaySpacing.s12, + ZplaySpacing.s8, + ), + child: SizedBox( + height: 38, + child: TextField( + controller: _catSearchCtrl, + style: ZplayType.bodySmall.toStyle( + color: tokens.textPrimary, + ), + decoration: InputDecoration( + hintText: 'Filter categories…', + hintStyle: ZplayType.bodySmall.toStyle( + color: tokens.textMuted, ), - child: SizedBox( - height: 38, - child: TextField( - controller: _catSearchCtrl, - style: ZplayType.bodySmall.toStyle(color: tokens.textPrimary), - decoration: InputDecoration( - hintText: 'Filter categories…', - hintStyle: ZplayType.bodySmall.toStyle(color: tokens.textMuted), - prefixIcon: Icon(Icons.filter_list_rounded, color: tokens.textMuted, size: 18), - suffixIcon: _catSearchQuery.isNotEmpty - ? IconButton( - icon: Icon(Icons.close_rounded, color: tokens.textMuted, size: 16), - onPressed: () { - _catSearchCtrl.clear(); - setState(() => _catSearchQuery = ''); - }, - ) - : null, - filled: true, - fillColor: tokens.surface, - border: const OutlineInputBorder( - borderRadius: ZplayRadius.smAll, - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric(vertical: ZplaySpacing.s8), - ), - onChanged: (v) => setState(() => _catSearchQuery = v), - ), + prefixIcon: Icon( + Icons.filter_list_rounded, + color: tokens.textMuted, + size: 18, ), - ), - - // Category List with Desktop Vertical Scroll Arrows - Expanded( - child: MouseRegion( - onEnter: (_) => setState(() => _isHoveringCategories = true), - onExit: (_) => setState(() => _isHoveringCategories = false), - child: Stack( - children: [ - ListView.builder( - controller: _categoryScrollController, - itemExtent: 46.0, - cacheExtent: 300.0, - addAutomaticKeepAlives: false, - addRepaintBoundaries: true, - padding: const EdgeInsets.symmetric( - horizontal: ZplaySpacing.s8, - vertical: ZplaySpacing.s4, + suffixIcon: _catSearchQuery.isNotEmpty + ? IconButton( + icon: Icon( + Icons.close_rounded, + color: tokens.textMuted, + size: 16, ), - itemCount: _filteredCategories().length, - itemBuilder: (context, index) { - final cat = _filteredCategories()[index]; - final isSelected = _selectedCategoryId == cat.id; - final count = _countForCategory(cat.id); - - return _CategoryListRow( - category: cat, - count: count, - isSelected: isSelected, - onTap: () { - setState(() => _selectedCategoryId = cat.id); - _contentScrollController.jumpTo(0); - }, + onPressed: () { + _catSearchCtrl.clear(); + setState( + () => _catSearchQuery = '', ); }, - ), - - // Desktop Category Scroll Up Arrow - if (_isHoveringCategories && _canScrollCatUp) - Positioned( - top: ZplaySpacing.s4, - left: 0, - right: 0, - child: Center( - child: _VerticalScrollButton( - icon: Icons.keyboard_arrow_up_rounded, - onTap: () => _scrollCategories(-240), - ), - ), - ), - - // Desktop Category Scroll Down Arrow - if (_isHoveringCategories && _canScrollCatDown) - Positioned( - bottom: ZplaySpacing.s4, - left: 0, - right: 0, - child: Center( - child: _VerticalScrollButton( - icon: Icons.keyboard_arrow_down_rounded, - onTap: () => _scrollCategories(240), - ), - ), - ), - ], - ), + ) + : null, + filled: true, + fillColor: tokens.surface, + border: const OutlineInputBorder( + borderRadius: ZplayRadius.smAll, + borderSide: BorderSide.none, ), + contentPadding: + const EdgeInsets.symmetric( + vertical: ZplaySpacing.s8, + ), ), - ], + onChanged: (v) => + setState(() => _catSearchQuery = v), + ), ), ), - // ── RIGHT CONTENT PANEL ── + // Category List with Desktop Vertical Scroll Arrows Expanded( child: MouseRegion( - onEnter: (_) => setState(() => _isHoveringContent = true), - onExit: (_) => setState(() => _isHoveringContent = false), + onEnter: (_) => setState( + () => _isHoveringCategories = true, + ), + onExit: (_) => setState( + () => _isHoveringCategories = false, + ), child: Stack( children: [ - _buildMainContent(), + ListView.builder( + controller: _categoryScrollController, + itemExtent: 46.0, + cacheExtent: 300.0, + addAutomaticKeepAlives: false, + addRepaintBoundaries: true, + padding: const EdgeInsets.symmetric( + horizontal: ZplaySpacing.s8, + vertical: ZplaySpacing.s4, + ), + itemCount: + _filteredCategories().length, + itemBuilder: (context, index) { + final cat = + _filteredCategories()[index]; + final isSelected = + _selectedCategoryId == cat.id; + final count = _countForCategory( + cat.id, + ); + + return _CategoryListRow( + category: cat, + count: count, + isSelected: isSelected, + onTap: () { + setState( + () => _selectedCategoryId = + cat.id, + ); + _contentScrollController.jumpTo( + 0, + ); + }, + ); + }, + ), - // Desktop Content Scroll Up Arrow - if (_isHoveringContent && _canScrollContentUp) + // Desktop Category Scroll Up Arrow + if (_isHoveringCategories && + _canScrollCatUp) Positioned( - top: 14, - right: 28, - child: _VerticalScrollButton( - icon: Icons.keyboard_arrow_up_rounded, - onTap: () => _scrollContent(-450), + top: ZplaySpacing.s4, + left: 0, + right: 0, + child: Center( + child: _VerticalScrollButton( + icon: Icons + .keyboard_arrow_up_rounded, + onTap: () => + _scrollCategories(-240), + ), ), ), - // Desktop Content Scroll Down Arrow - if (_isHoveringContent && _canScrollContentDown) + // Desktop Category Scroll Down Arrow + if (_isHoveringCategories && + _canScrollCatDown) Positioned( - bottom: 14, - right: 28, - child: _VerticalScrollButton( - icon: Icons.keyboard_arrow_down_rounded, - onTap: () => _scrollContent(450), + bottom: ZplaySpacing.s4, + left: 0, + right: 0, + child: Center( + child: _VerticalScrollButton( + icon: Icons + .keyboard_arrow_down_rounded, + onTap: () => + _scrollCategories(240), + ), ), ), ], @@ -1304,13 +1553,54 @@ class _IptvPortalBrowserPageState extends State { ), ), ], - ) - : _buildMainContent(), - ), - ], + ), + ), + + // ── RIGHT CONTENT PANEL ── + Expanded( + child: MouseRegion( + onEnter: (_) => + setState(() => _isHoveringContent = true), + onExit: (_) => + setState(() => _isHoveringContent = false), + child: Stack( + children: [ + _buildMainContent(), + + // Desktop Content Scroll Up Arrow + if (_isHoveringContent && _canScrollContentUp) + Positioned( + top: 14, + right: 28, + child: _VerticalScrollButton( + icon: Icons.keyboard_arrow_up_rounded, + onTap: () => _scrollContent(-450), + ), + ), + + // Desktop Content Scroll Down Arrow + if (_isHoveringContent && + _canScrollContentDown) + Positioned( + bottom: 14, + right: 28, + child: _VerticalScrollButton( + icon: Icons.keyboard_arrow_down_rounded, + onTap: () => _scrollContent(450), + ), + ), + ], + ), + ), + ), + ], + ) + : _buildMainContent(), + ), + ], + ), ), ), - ), ); } @@ -1353,8 +1643,14 @@ class _IptvPortalBrowserPageState extends State { Text( label, style: ZplayType.label - .copyWith(weight: isSelected ? FontWeight.w700 : FontWeight.w500) - .toStyle(color: isSelected ? tokens.onAccent : tokens.textSecondary), + .copyWith( + weight: isSelected ? FontWeight.w700 : FontWeight.w500, + ) + .toStyle( + color: isSelected + ? tokens.onAccent + : tokens.textSecondary, + ), ), ], ), @@ -1375,10 +1671,16 @@ class _IptvPortalBrowserPageState extends State { Container( padding: const EdgeInsets.all(ZplaySpacing.s16), decoration: BoxDecoration( - color: tokens.warning.withValues(alpha: ZplayOpacity.overlayHover), + color: tokens.warning.withValues( + alpha: ZplayOpacity.overlayHover, + ), shape: BoxShape.circle, ), - child: Icon(Icons.star_outline_rounded, color: tokens.warning, size: 48), + child: Icon( + Icons.star_outline_rounded, + color: tokens.warning, + size: 48, + ), ), const SizedBox(height: ZplaySpacing.s16), Text( @@ -1430,7 +1732,12 @@ class _IptvPortalBrowserPageState extends State { cacheExtent: 400.0, addAutomaticKeepAlives: false, addRepaintBoundaries: true, - padding: EdgeInsets.fromLTRB(isDesktop ? 20 : 10, 12, isDesktop ? 20 : 10, 30), + padding: EdgeInsets.fromLTRB( + isDesktop ? 20 : 10, + 12, + isDesktop ? 20 : 10, + 30, + ), physics: const BouncingScrollPhysics(), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: gridCols, @@ -1463,7 +1770,12 @@ class _IptvPortalBrowserPageState extends State { cacheExtent: 400.0, addAutomaticKeepAlives: false, addRepaintBoundaries: true, - padding: EdgeInsets.fromLTRB(isDesktop ? 20 : 10, 12, isDesktop ? 20 : 10, 30), + padding: EdgeInsets.fromLTRB( + isDesktop ? 20 : 10, + 12, + isDesktop ? 20 : 10, + 30, + ), physics: const BouncingScrollPhysics(), itemCount: streams.length, itemBuilder: (context, index) { @@ -1494,7 +1806,12 @@ class _IptvPortalBrowserPageState extends State { cacheExtent: 400.0, addAutomaticKeepAlives: false, addRepaintBoundaries: true, - padding: EdgeInsets.fromLTRB(isDesktop ? 20 : 10, 12, isDesktop ? 20 : 10, 30), + padding: EdgeInsets.fromLTRB( + isDesktop ? 20 : 10, + 12, + isDesktop ? 20 : 10, + 30, + ), physics: const BouncingScrollPhysics(), itemCount: streams.length, itemBuilder: (context, index) { @@ -1541,7 +1858,12 @@ class _IptvPortalBrowserPageState extends State { cacheExtent: 400.0, addAutomaticKeepAlives: false, addRepaintBoundaries: true, - padding: EdgeInsets.fromLTRB(isDesktop ? 20 : 10, 12, isDesktop ? 20 : 10, 30), + padding: EdgeInsets.fromLTRB( + isDesktop ? 20 : 10, + 12, + isDesktop ? 20 : 10, + 30, + ), physics: const BouncingScrollPhysics(), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, @@ -1595,7 +1917,8 @@ class _CategoryListRow extends StatelessWidget { @override Widget build(BuildContext context) { final tokens = context.tokens; - final isFavCategory = category.id == _IptvPortalBrowserPageState.favoritesCategoryId; + final isFavCategory = + category.id == _IptvPortalBrowserPageState.favoritesCategoryId; final showCount = IptvSettings.showCategoryCount.value; return RepaintBoundary( @@ -1616,7 +1939,9 @@ class _CategoryListRow extends StatelessWidget { // lift. Neither draws a border, and unselected rows stay flat. color: isSelected ? tokens.accentSubtle - : (state.highlighted ? tokens.borderSubtle : Colors.transparent), + : (state.highlighted + ? tokens.borderSubtle + : Colors.transparent), borderRadius: ZplayRadius.smAll, ), child: Row( @@ -1641,8 +1966,16 @@ class _CategoryListRow extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: ZplayType.label - .copyWith(weight: isSelected ? FontWeight.w700 : FontWeight.w500) - .toStyle(color: isSelected ? tokens.textPrimary : tokens.textMuted), + .copyWith( + weight: isSelected + ? FontWeight.w700 + : FontWeight.w500, + ) + .toStyle( + color: isSelected + ? tokens.textPrimary + : tokens.textMuted, + ), ), ), if (showCount) ...[ @@ -1693,16 +2026,26 @@ class _LiveChannelListRowState extends State<_LiveChannelListRow> { @override void initState() { super.initState(); - _cachedEpg = _IptvPortalBrowserPageState._sharedEpgCache[widget.stream.streamId]; + _cachedEpg = + _IptvPortalBrowserPageState._sharedEpgCache[widget.stream.streamId]; } void _loadEpg() async { if (!IptvSettings.showEpgSnippet.value) return; - if (_cachedEpg != null || widget.portal == null || widget.stream.streamId.isEmpty) return; + if (_cachedEpg != null || + widget.portal == null || + widget.stream.streamId.isEmpty) { + return; + } try { - final entries = await IptvClient.shortEpg(widget.portal!.portal, widget.stream.streamId, limit: 2); + final entries = await IptvClient.shortEpg( + widget.portal!.portal, + widget.stream.streamId, + limit: 2, + ); if (mounted && entries.isNotEmpty) { - _IptvPortalBrowserPageState._sharedEpgCache[widget.stream.streamId] = entries; + _IptvPortalBrowserPageState._sharedEpgCache[widget.stream.streamId] = + entries; setState(() => _cachedEpg = entries); } } catch (_) {} @@ -1739,8 +2082,12 @@ class _LiveChannelListRowState extends State<_LiveChannelListRow> { final tokens = context.tokens; final s = widget.stream; final indexFormatted = widget.index.toString().padLeft(3, '0'); - final currentEpg = _cachedEpg?.isNotEmpty == true ? _cachedEpg!.first : null; - final nextEpg = _cachedEpg != null && _cachedEpg!.length > 1 ? _cachedEpg![1] : null; + final currentEpg = _cachedEpg?.isNotEmpty == true + ? _cachedEpg!.first + : null; + final nextEpg = _cachedEpg != null && _cachedEpg!.length > 1 + ? _cachedEpg![1] + : null; final screenW = MediaQuery.sizeOf(context).width; final isVerySmall = screenW < 440; final isCompact = FormFactorService.of(context) == FormFactor.compact; @@ -1787,7 +2134,7 @@ class _LiveChannelListRowState extends State<_LiveChannelListRow> { ), const SizedBox(width: ZplaySpacing.s8), ], - + // ── CHANNEL LOGO BAY ── if (showLogo) ...[ Container( @@ -1806,13 +2153,24 @@ class _LiveChannelListRowState extends State<_LiveChannelListRow> { cacheManager: AppImageCache.manager, fit: BoxFit.contain, memCacheWidth: 128, - errorWidget: (_, _, _) => Icon(Icons.live_tv_rounded, color: tokens.textDisabled, size: 20)), + errorWidget: (_, _, _) => Icon( + Icons.live_tv_rounded, + color: tokens.textDisabled, + size: 20, + ), + ), ) - : Icon(Icons.live_tv_rounded, color: tokens.textDisabled, size: 20), + : Icon( + Icons.live_tv_rounded, + color: tokens.textDisabled, + size: 20, + ), + ), + SizedBox( + width: isVerySmall ? ZplaySpacing.s8 : ZplaySpacing.s12, ), - SizedBox(width: isVerySmall ? ZplaySpacing.s8 : ZplaySpacing.s12), ], - + // ── CHANNEL IDENTITY ── // Prototype `.channel-logo-badge`: a short-name chip, then the // name / real format tag stack. @@ -1846,7 +2204,9 @@ class _LiveChannelListRowState extends State<_LiveChannelListRow> { s.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: ZplayType.subtitle.toStyle(color: tokens.textPrimary), + style: ZplayType.subtitle.toStyle( + color: tokens.textPrimary, + ), ), if (meta.isNotEmpty) ...[ @@ -1855,7 +2215,9 @@ class _LiveChannelListRowState extends State<_LiveChannelListRow> { meta, maxLines: 1, overflow: TextOverflow.ellipsis, - style: ZplayType.caption.toStyle(color: tokens.textMuted), + style: ZplayType.caption.toStyle( + color: tokens.textMuted, + ), ), ], @@ -1867,20 +2229,28 @@ class _LiveChannelListRowState extends State<_LiveChannelListRow> { children: [ TextSpan( text: 'NOW: ', - style: ZplayType.caption.toStyle(color: tokens.textMuted), + style: ZplayType.caption.toStyle( + color: tokens.textMuted, + ), ), TextSpan( text: currentEpg.title, - style: ZplayType.caption.toStyle(color: tokens.textPrimary), + style: ZplayType.caption.toStyle( + color: tokens.textPrimary, + ), ), if (nextEpg != null) ...[ TextSpan( text: ' NEXT: ', - style: ZplayType.caption.toStyle(color: tokens.textMuted), + style: ZplayType.caption.toStyle( + color: tokens.textMuted, + ), ), TextSpan( text: nextEpg.title, - style: ZplayType.caption.toStyle(color: tokens.textMuted), + style: ZplayType.caption.toStyle( + color: tokens.textMuted, + ), ), ], ], @@ -1896,7 +2266,9 @@ class _LiveChannelListRowState extends State<_LiveChannelListRow> { child: ClipRRect( borderRadius: ZplayRadius.fullAll, child: ColoredBox( - color: tokens.textPrimary.withValues(alpha: ZplayOpacity.borderMedium), + color: tokens.textPrimary.withValues( + alpha: ZplayOpacity.borderMedium, + ), child: FractionallySizedBox( alignment: Alignment.centerLeft, widthFactor: progress, @@ -1909,13 +2281,15 @@ class _LiveChannelListRowState extends State<_LiveChannelListRow> { ] else Text( 'Live Stream Feed', - style: ZplayType.caption.toStyle(color: tokens.textMuted), + style: ZplayType.caption.toStyle( + color: tokens.textMuted, + ), ), ], ], ), ), - + const SizedBox(width: ZplaySpacing.s8), // LIVE tag — fill only, no outline (prototype `.channel-badge-live`). @@ -1938,7 +2312,7 @@ class _LiveChannelListRowState extends State<_LiveChannelListRow> { ), ), ), - + const SizedBox(width: ZplaySpacing.s4), // Favorite Button @@ -1947,11 +2321,17 @@ class _LiveChannelListRowState extends State<_LiveChannelListRow> { height: actionSize, child: IconButton( icon: Icon( - widget.isFavorite ? Icons.star_rounded : Icons.star_outline_rounded, - color: widget.isFavorite ? tokens.warning : tokens.textMuted, + widget.isFavorite + ? Icons.star_rounded + : Icons.star_outline_rounded, + color: widget.isFavorite + ? tokens.warning + : tokens.textMuted, size: 21, ), - tooltip: widget.isFavorite ? 'Remove from Favorites' : 'Add to Favorites', + tooltip: widget.isFavorite + ? 'Remove from Favorites' + : 'Add to Favorites', onPressed: widget.onToggleFavorite, ), ), @@ -1975,7 +2355,8 @@ class _LiveChannelListRowState extends State<_LiveChannelListRow> { width: 32, height: 32, decoration: BoxDecoration( - color: (state.highlighted || playState.highlighted) + color: + (state.highlighted || playState.highlighted) ? tokens.accent : tokens.borderSubtle, shape: BoxShape.circle, @@ -2069,7 +2450,12 @@ class _LiveChannelGridCardState extends State<_LiveChannelGridCard> { cacheManager: AppImageCache.manager, fit: BoxFit.contain, memCacheWidth: 100, - errorWidget: (_, _, _) => Icon(Icons.live_tv_rounded, color: tokens.textDisabled, size: 16)), + errorWidget: (_, _, _) => Icon( + Icons.live_tv_rounded, + color: tokens.textDisabled, + size: 16, + ), + ), ), ) else @@ -2084,7 +2470,9 @@ class _LiveChannelGridCardState extends State<_LiveChannelGridCard> { ), child: Text( '#${widget.index}', - style: ZplayType.caption.toStyle(color: tokens.textSecondary), + style: ZplayType.caption.toStyle( + color: tokens.textSecondary, + ), ), ), @@ -2124,8 +2512,12 @@ class _LiveChannelGridCardState extends State<_LiveChannelGridCard> { radius: ZplayRadius.fullAll, child: Center( child: Icon( - widget.isFavorite ? Icons.star_rounded : Icons.star_outline_rounded, - color: widget.isFavorite ? tokens.warning : tokens.textMuted, + widget.isFavorite + ? Icons.star_rounded + : Icons.star_outline_rounded, + color: widget.isFavorite + ? tokens.warning + : tokens.textMuted, size: 19, ), ), @@ -2157,12 +2549,16 @@ class _LiveChannelGridCardState extends State<_LiveChannelGridCard> { vertical: ZplaySpacing.s2, ), decoration: BoxDecoration( - color: tokens.textPrimary.withValues(alpha: ZplayOpacity.borderMedium), + color: tokens.textPrimary.withValues( + alpha: ZplayOpacity.borderMedium, + ), borderRadius: ZplayRadius.xsAll, ), child: Text( s.containerExt.toUpperCase(), - style: ZplayType.overline.toStyle(color: tokens.textEmphasis), + style: ZplayType.overline.toStyle( + color: tokens.textEmphasis, + ), ), ), AnimatedContainer( @@ -2171,7 +2567,9 @@ class _LiveChannelGridCardState extends State<_LiveChannelGridCard> { width: 26, height: 26, decoration: BoxDecoration( - color: state.highlighted ? tokens.accent : tokens.borderSubtle, + color: state.highlighted + ? tokens.accent + : tokens.borderSubtle, shape: BoxShape.circle, ), child: Icon( @@ -2212,10 +2610,12 @@ class _LiveChannelCompactListRow extends StatefulWidget { }); @override - State<_LiveChannelCompactListRow> createState() => _LiveChannelCompactListRowState(); + State<_LiveChannelCompactListRow> createState() => + _LiveChannelCompactListRowState(); } -class _LiveChannelCompactListRowState extends State<_LiveChannelCompactListRow> { +class _LiveChannelCompactListRowState + extends State<_LiveChannelCompactListRow> { @override Widget build(BuildContext context) { final tokens = context.tokens; @@ -2246,7 +2646,9 @@ class _LiveChannelCompactListRowState extends State<_LiveChannelCompactListRow> width: 28, child: Text( widget.index.toString().padLeft(3, '0'), - style: ZplayType.labelNumeric.toStyle(color: tokens.textDisabled), + style: ZplayType.labelNumeric.toStyle( + color: tokens.textDisabled, + ), ), ), if (showLogo && s.icon.isNotEmpty) ...[ @@ -2261,7 +2663,12 @@ class _LiveChannelCompactListRowState extends State<_LiveChannelCompactListRow> cacheManager: AppImageCache.manager, fit: BoxFit.contain, memCacheWidth: 64, - errorWidget: (_, _, _) => Icon(Icons.live_tv_rounded, color: tokens.textDisabled, size: 14)), + errorWidget: (_, _, _) => Icon( + Icons.live_tv_rounded, + color: tokens.textDisabled, + size: 14, + ), + ), ), ), ], @@ -2271,7 +2678,9 @@ class _LiveChannelCompactListRowState extends State<_LiveChannelCompactListRow> s.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: ZplayType.subtitle.toStyle(color: tokens.textPrimary), + style: ZplayType.subtitle.toStyle( + color: tokens.textPrimary, + ), ), ), if (widget.isAlive) @@ -2302,8 +2711,12 @@ class _LiveChannelCompactListRowState extends State<_LiveChannelCompactListRow> focused: starState.focused, radius: ZplayRadius.fullAll, child: Icon( - widget.isFavorite ? Icons.star_rounded : Icons.star_outline_rounded, - color: widget.isFavorite ? tokens.warning : tokens.textMuted, + widget.isFavorite + ? Icons.star_rounded + : Icons.star_outline_rounded, + color: widget.isFavorite + ? tokens.warning + : tokens.textMuted, size: 18, ), ), @@ -2381,10 +2794,19 @@ class _VodSeriesCardState extends State<_VodSeriesCard> { fit: BoxFit.cover, memCacheWidth: 256, errorWidget: (_, _, _) => Center( - child: Icon(Icons.movie_rounded, color: tokens.textDisabled, size: 32), - )) + child: Icon( + Icons.movie_rounded, + color: tokens.textDisabled, + size: 32, + ), + ), + ) : Center( - child: Icon(Icons.movie_rounded, color: tokens.textDisabled, size: 32), + child: Icon( + Icons.movie_rounded, + color: tokens.textDisabled, + size: 32, + ), ), // Over-art scrim: keeps the play glyph readable on any still. if (state.highlighted) @@ -2392,7 +2814,11 @@ class _VodSeriesCardState extends State<_VodSeriesCard> { child: ColoredBox( color: const Color(0x73000000), child: Center( - child: Icon(Icons.play_circle_fill_rounded, color: tokens.accent, size: 40), + child: Icon( + Icons.play_circle_fill_rounded, + color: tokens.accent, + size: 40, + ), ), ), ), @@ -2407,15 +2833,21 @@ class _VodSeriesCardState extends State<_VodSeriesCard> { focused: starState.focused, radius: ZplayRadius.fullAll, child: Container( - padding: const EdgeInsets.all(ZplaySpacing.s4), + padding: const EdgeInsets.all( + ZplaySpacing.s4, + ), decoration: const BoxDecoration( // Scrim over artwork so the glyph stays legible. color: Color(0xBF000000), shape: BoxShape.circle, ), child: Icon( - widget.isFavorite ? Icons.star_rounded : Icons.star_outline_rounded, - color: widget.isFavorite ? tokens.warning : tokens.textPrimary, + widget.isFavorite + ? Icons.star_rounded + : Icons.star_outline_rounded, + color: widget.isFavorite + ? tokens.warning + : tokens.textPrimary, size: 16, ), ), @@ -2466,7 +2898,10 @@ class _SeriesEpisodesSheetState extends State<_SeriesEpisodesSheet> { Future _loadEpisodes() async { try { - final eps = await IptvClient.seriesEpisodes(widget.portal.portal, widget.series.streamId); + final eps = await IptvClient.seriesEpisodes( + widget.portal.portal, + widget.series.streamId, + ); if (mounted) { setState(() { _episodes = eps; @@ -2507,7 +2942,8 @@ class _SeriesEpisodesSheetState extends State<_SeriesEpisodesSheet> { ? hits[initialIndex].stream : IptvStream( streamId: ep.id, - name: '${widget.series.name} - S${ep.season}E${ep.episode} ${ep.title}', + name: + '${widget.series.name} - S${ep.season}E${ep.episode} ${ep.title}', icon: ep.image.isNotEmpty ? ep.image : widget.series.icon, categoryId: widget.series.categoryId, containerExt: ep.containerExt, @@ -2535,7 +2971,10 @@ class _SeriesEpisodesSheetState extends State<_SeriesEpisodesSheet> { ChannelHit( portal: widget.portal, stream: currentStream, - streamUrl: IptvClient.streamUrl(widget.portal.portal, currentStream), + streamUrl: IptvClient.streamUrl( + widget.portal.portal, + currentStream, + ), ), ], initialHitIndex: initialIndex, @@ -2563,11 +3002,16 @@ class _SeriesEpisodesSheetState extends State<_SeriesEpisodesSheet> { // Handle Center( child: Container( - margin: const EdgeInsets.only(top: ZplaySpacing.s12, bottom: ZplaySpacing.s8), + margin: const EdgeInsets.only( + top: ZplaySpacing.s12, + bottom: ZplaySpacing.s8, + ), width: 44, height: 4.5, decoration: BoxDecoration( - color: tokens.textPrimary.withValues(alpha: ZplayOpacity.overlayHover), + color: tokens.textPrimary.withValues( + alpha: ZplayOpacity.overlayHover, + ), borderRadius: BorderRadius.circular(3), ), ), @@ -2597,70 +3041,82 @@ class _SeriesEpisodesSheetState extends State<_SeriesEpisodesSheet> { child: _isLoading ? Center(child: CircularProgressIndicator(color: tokens.accent)) : _error != null - ? ErrorView( - error: _error, - onRetry: _loadEpisodes, - title: 'Could not load episodes', - ) - : ListView.separated( - padding: const EdgeInsets.symmetric( - horizontal: ZplaySpacing.s20, - vertical: ZplaySpacing.s8, - ), - itemCount: _episodes.length, - separatorBuilder: (_, _) => const SizedBox(height: ZplaySpacing.s8), - itemBuilder: (context, index) { - final ep = _episodes[index]; - return FocusableCard( - onTap: () => _playEpisode(ep), - builder: (context, state) => CardFocusRing( - focused: state.focused, - radius: ZplayRadius.smAll, - child: AnimatedContainer( - duration: ZplayMotion.fast, - curve: ZplayMotion.standard, - padding: const EdgeInsets.symmetric( - horizontal: ZplaySpacing.s12, - vertical: ZplaySpacing.s12, - ), - decoration: BoxDecoration( - color: state.highlighted ? tokens.borderStrong : tokens.surface, - borderRadius: ZplayRadius.smAll, - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: ZplaySpacing.s8, - vertical: ZplaySpacing.s4, - ), - decoration: BoxDecoration( - color: tokens.accentSubtle, - borderRadius: ZplayRadius.xsAll, - ), - child: Text( - 'S${ep.season}E${ep.episode}', - style: ZplayType.label.toStyle(color: tokens.accent), - ), + ? ErrorView( + error: _error, + onRetry: _loadEpisodes, + title: 'Could not load episodes', + ) + : ListView.separated( + padding: const EdgeInsets.symmetric( + horizontal: ZplaySpacing.s20, + vertical: ZplaySpacing.s8, + ), + itemCount: _episodes.length, + separatorBuilder: (_, _) => + const SizedBox(height: ZplaySpacing.s8), + itemBuilder: (context, index) { + final ep = _episodes[index]; + return FocusableCard( + onTap: () => _playEpisode(ep), + builder: (context, state) => CardFocusRing( + focused: state.focused, + radius: ZplayRadius.smAll, + child: AnimatedContainer( + duration: ZplayMotion.fast, + curve: ZplayMotion.standard, + padding: const EdgeInsets.symmetric( + horizontal: ZplaySpacing.s12, + vertical: ZplaySpacing.s12, + ), + decoration: BoxDecoration( + color: state.highlighted + ? tokens.borderStrong + : tokens.surface, + borderRadius: ZplayRadius.smAll, + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: ZplaySpacing.s8, + vertical: ZplaySpacing.s4, + ), + decoration: BoxDecoration( + color: tokens.accentSubtle, + borderRadius: ZplayRadius.xsAll, + ), + child: Text( + 'S${ep.season}E${ep.episode}', + style: ZplayType.label.toStyle( + color: tokens.accent, ), - const SizedBox(width: ZplaySpacing.s12), - Expanded( - child: Text( - ep.title.isNotEmpty ? ep.title : 'Episode ${ep.episode}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: ZplayType.subtitle.toStyle(color: tokens.textPrimary), - ), + ), + ), + const SizedBox(width: ZplaySpacing.s12), + Expanded( + child: Text( + ep.title.isNotEmpty + ? ep.title + : 'Episode ${ep.episode}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ZplayType.subtitle.toStyle( + color: tokens.textPrimary, ), - const SizedBox(width: ZplaySpacing.s8), - Icon(Icons.play_circle_fill_rounded, color: tokens.accent), - ], + ), ), - ), + const SizedBox(width: ZplaySpacing.s8), + Icon( + Icons.play_circle_fill_rounded, + color: tokens.accent, + ), + ], ), - ); - }, - ), + ), + ), + ); + }, + ), ), ], ), diff --git a/lib/pages/manga/manga_reader_page.dart b/lib/pages/manga/manga_reader_page.dart index 47cd258e..77b1f083 100644 --- a/lib/pages/manga/manga_reader_page.dart +++ b/lib/pages/manga/manga_reader_page.dart @@ -54,7 +54,8 @@ class _MangaReaderPageState extends State { final ScrollController _verticalScrollController = ScrollController(); final ScrollController _deckScrollController = ScrollController(); Timer? _progressSaveTimer; - final TransformationController _transformationController = TransformationController(); + final TransformationController _transformationController = + TransformationController(); double _currentZoom = 1.0; @@ -119,7 +120,10 @@ class _MangaReaderPageState extends State { _currentZoom = 1.0; _transformationController.value = Matrix4.identity(); } else { - _transformationController.value = _buildCenterScaleMatrix(_currentZoom, size); + _transformationController.value = _buildCenterScaleMatrix( + _currentZoom, + size, + ); } }); } @@ -132,7 +136,10 @@ class _MangaReaderPageState extends State { _currentZoom = 1.0; _transformationController.value = Matrix4.identity(); } else { - _transformationController.value = _buildCenterScaleMatrix(_currentZoom, size); + _transformationController.value = _buildCenterScaleMatrix( + _currentZoom, + size, + ); } }); } @@ -151,8 +158,12 @@ class _MangaReaderPageState extends State { final chapter = widget.chapters[_currentChapterIndex]; DiscordRpcService.instance.setReadingManga( title: widget.manga.title, - chapter: chapter.name.isNotEmpty ? chapter.name : 'Chapter ${chapter.number}', - coverUrl: widget.manga.coverNormal.isNotEmpty ? widget.manga.coverNormal : widget.manga.coverSmall, + chapter: chapter.name.isNotEmpty + ? chapter.name + : 'Chapter ${chapter.number}', + coverUrl: widget.manga.coverNormal.isNotEmpty + ? widget.manga.coverNormal + : widget.manga.coverSmall, ); final urls = await _mangaService.getChapterImages(chapter.id); @@ -169,8 +180,10 @@ class _MangaReaderPageState extends State { // Keep a bounded window warm instead of decoding the whole chapter. The // deck is a lazy list, so far-off thumbnails do not need to be fetched // until they are actually scrolled into view. - final pageCacheWidth = - (MediaQuery.sizeOf(context).width * 2).round().clamp(96, 2048).toInt(); + final pageCacheWidth = (MediaQuery.sizeOf(context).width * 2) + .round() + .clamp(96, 2048) + .toInt(); if (urls.isNotEmpty) { final thumbStart = _currentPageIndex - 10 < 0 ? 0 @@ -198,7 +211,10 @@ class _MangaReaderPageState extends State { ResizeImage.resizeIfNeeded( pageCacheWidth, null, - CachedNetworkImageProvider(urls[i], cacheManager: AppImageCache.manager), + CachedNetworkImageProvider( + urls[i], + cacheManager: AppImageCache.manager, + ), ), context, ); @@ -230,7 +246,11 @@ class _MangaReaderPageState extends State { } void _scrollToCurrentPageInDeck() { - if (!_showDeckDrawer || !_deckScrollController.hasClients || _pageUrls.isEmpty) return; + if (!_showDeckDrawer || + !_deckScrollController.hasClients || + _pageUrls.isEmpty) { + return; + } final screenW = MediaQuery.sizeOf(context).width; final isMobile = screenW < 600; final isVerySmall = screenW < 420; @@ -247,7 +267,10 @@ class _MangaReaderPageState extends State { if (_readingMode != MangaReadingMode.webtoon || _pageUrls.isEmpty) return; final offset = _verticalScrollController.offset; - final estimatedIndex = (offset / 900).floor().clamp(0, _pageUrls.length - 1); + final estimatedIndex = (offset / 900).floor().clamp( + 0, + _pageUrls.length - 1, + ); if (estimatedIndex != _currentPageIndex) { setState(() => _currentPageIndex = estimatedIndex); @@ -270,7 +293,10 @@ class _MangaReaderPageState extends State { if (_verticalScrollController.hasClients) { final targetOffset = pageIndex * 900.0; _verticalScrollController.animateTo( - targetOffset.clamp(0.0, _verticalScrollController.position.maxScrollExtent), + targetOffset.clamp( + 0.0, + _verticalScrollController.position.maxScrollExtent, + ), duration: const Duration(milliseconds: 350), curve: Curves.easeInOut, ); @@ -334,13 +360,19 @@ class _MangaReaderPageState extends State { if (event.logicalKey == LogicalKeyboardKey.arrowRight || event.logicalKey == LogicalKeyboardKey.space) { if (_currentPageIndex < _pageUrls.length - 1) { - _pageController?.nextPage(duration: const Duration(milliseconds: 250), curve: Curves.easeInOut); + _pageController?.nextPage( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + ); } else { _nextChapter(); } } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { if (_currentPageIndex > 0) { - _pageController?.previousPage(duration: const Duration(milliseconds: 250), curve: Curves.easeInOut); + _pageController?.previousPage( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + ); } else { _prevChapter(); } @@ -349,13 +381,19 @@ class _MangaReaderPageState extends State { if (event.logicalKey == LogicalKeyboardKey.arrowLeft || event.logicalKey == LogicalKeyboardKey.space) { if (_currentPageIndex < _pageUrls.length - 1) { - _pageController?.nextPage(duration: const Duration(milliseconds: 250), curve: Curves.easeInOut); + _pageController?.nextPage( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + ); } else { _nextChapter(); } } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { if (_currentPageIndex > 0) { - _pageController?.previousPage(duration: const Duration(milliseconds: 250), curve: Curves.easeInOut); + _pageController?.previousPage( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + ); } else { _prevChapter(); } @@ -386,7 +424,11 @@ class _MangaReaderPageState extends State { children: [ Row( children: [ - Icon(Icons.tune_rounded, color: palette.primaryColor, size: 20), + Icon( + Icons.tune_rounded, + color: palette.primaryColor, + size: 20, + ), const SizedBox(width: 10), Text( 'Reader Settings & Layout', @@ -402,7 +444,8 @@ class _MangaReaderPageState extends State { icon: Icons.close_rounded, semanticLabel: 'Close', iconSize: 20, - tapTarget: FormFactorService.of(ctx) == FormFactor.compact + tapTarget: + FormFactorService.of(ctx) == FormFactor.compact ? 40.0 : kMinInteractiveDimension, onTap: () => Navigator.pop(ctx), @@ -413,7 +456,12 @@ class _MangaReaderPageState extends State { Divider(color: tokens.borderDefault), const SizedBox(height: 12), - Text('Reading Orientation Mode', style: ZplayType.label.copyWith(weight: FontWeight.w700).toStyle(color: tokens.textPrimary)), + Text( + 'Reading Orientation Mode', + style: ZplayType.label + .copyWith(weight: FontWeight.w700) + .toStyle(color: tokens.textPrimary), + ), const SizedBox(height: 8), SegmentedTabs( semanticsLabel: 'Reading orientation mode', @@ -431,7 +479,12 @@ class _MangaReaderPageState extends State { const SizedBox(height: 14), - Text('Page Reading Width Constraint', style: ZplayType.label.copyWith(weight: FontWeight.w700).toStyle(color: tokens.textPrimary)), + Text( + 'Page Reading Width Constraint', + style: ZplayType.label + .copyWith(weight: FontWeight.w700) + .toStyle(color: tokens.textPrimary), + ), const SizedBox(height: 8), ValueListenableBuilder( valueListenable: MangaSettings.readerMaxWidth, @@ -453,7 +506,12 @@ class _MangaReaderPageState extends State { const SizedBox(height: 14), - Text('Reader Background Atmosphere', style: ZplayType.label.copyWith(weight: FontWeight.w700).toStyle(color: tokens.textPrimary)), + Text( + 'Reader Background Atmosphere', + style: ZplayType.label + .copyWith(weight: FontWeight.w700) + .toStyle(color: tokens.textPrimary), + ), const SizedBox(height: 8), const MangaAtmosphereChips(showLabel: false), @@ -464,7 +522,12 @@ class _MangaReaderPageState extends State { builder: (context, showDeck, _) { return SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, - title: Text('Show Page Deck Previews Drawer', style: ZplayType.label.toStyle(color: tokens.textPrimary)), + title: Text( + 'Show Page Deck Previews Drawer', + style: ZplayType.label.toStyle( + color: tokens.textPrimary, + ), + ), value: showDeck, activeColor: palette.primaryColor, onChanged: (val) => MangaSettings.setShowPageDeck(val), @@ -477,10 +540,16 @@ class _MangaReaderPageState extends State { builder: (context, showNext, _) { return SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, - title: Text('Show Next Chapter Deck Card', style: ZplayType.label.toStyle(color: tokens.textPrimary)), + title: Text( + 'Show Next Chapter Deck Card', + style: ZplayType.label.toStyle( + color: tokens.textPrimary, + ), + ), value: showNext, activeColor: palette.primaryColor, - onChanged: (val) => MangaSettings.setEnableNextChapterDeck(val), + onChanged: (val) => + MangaSettings.setEnableNextChapterDeck(val), ); }, ), @@ -515,32 +584,37 @@ class _MangaReaderPageState extends State { onTap: _toggleOverlay, child: _isLoading ? Center( - child: CircularProgressIndicator(color: palette.primaryColor), + child: CircularProgressIndicator( + color: palette.primaryColor, + ), ) : _pageUrls.isEmpty - ? Center( - child: Text( - 'No pages found for this chapter.', - style: ZplayType.subtitle.toStyle(color: tokens.textEmphasis), - ), - ) - : InteractiveViewer( - transformationController: _transformationController, - minScale: 0.25, - maxScale: 5.0, - panEnabled: true, - scaleEnabled: true, - clipBehavior: Clip.none, - onInteractionUpdate: (_) { - final s = _transformationController.value.getMaxScaleOnAxis(); - if ((s - _currentZoom).abs() > 0.05) { - setState(() => _currentZoom = s); - } - }, - child: _readingMode == MangaReadingMode.webtoon - ? _buildVerticalWebtoonReader() - : _buildHorizontalPageView(), + ? Center( + child: Text( + 'No pages found for this chapter.', + style: ZplayType.subtitle.toStyle( + color: tokens.textEmphasis, ), + ), + ) + : InteractiveViewer( + transformationController: _transformationController, + minScale: 0.25, + maxScale: 5.0, + panEnabled: true, + scaleEnabled: true, + clipBehavior: Clip.none, + onInteractionUpdate: (_) { + final s = _transformationController.value + .getMaxScaleOnAxis(); + if ((s - _currentZoom).abs() > 0.05) { + setState(() => _currentZoom = s); + } + }, + child: _readingMode == MangaReadingMode.webtoon + ? _buildVerticalWebtoonReader() + : _buildHorizontalPageView(), + ), ), // Custom Vertical Scroller for Webtoon Mode (Desktop only, removed for mobile) @@ -609,14 +683,22 @@ class _MangaReaderPageState extends State { cacheManager: AppImageCache.manager, // Page view keeps one full-res page; 2x screen width stays crisp // under the 5x zoom InteractiveViewer while bounding the decode. - memCacheWidth: (MediaQuery.sizeOf(context).width * 2).round().clamp(96, 2048).toInt(), + memCacheWidth: (MediaQuery.sizeOf(context).width * 2) + .round() + .clamp(96, 2048) + .toInt(), fit: BoxFit.contain, placeholder: (context, url) => Center( child: CircularProgressIndicator(color: tokens.textDisabled), ), errorWidget: (context, url, error) => Center( - child: Icon(Icons.broken_image_rounded, color: tokens.textMuted, size: 48), - )), + child: Icon( + Icons.broken_image_rounded, + color: tokens.textMuted, + size: 48, + ), + ), + ), ), ); }, @@ -655,20 +737,30 @@ class _MangaReaderPageState extends State { imageUrl: _pageUrls[index], cacheManager: AppImageCache.manager, // Webtoon strip keeps full-res (2x screen, <=2048) for 5x zoom. - memCacheWidth: (MediaQuery.sizeOf(context).width * 2).round().clamp(96, 2048).toInt(), + memCacheWidth: (MediaQuery.sizeOf(context).width * 2) + .round() + .clamp(96, 2048) + .toInt(), fit: BoxFit.fitWidth, placeholder: (context, url) => SizedBox( height: 350, child: Center( - child: CircularProgressIndicator(color: tokens.textDisabled), + child: CircularProgressIndicator( + color: tokens.textDisabled, + ), ), ), errorWidget: (context, url, error) => SizedBox( height: 200, child: Center( - child: Icon(Icons.broken_image_rounded, color: tokens.textMuted, size: 48), + child: Icon( + Icons.broken_image_rounded, + color: tokens.textMuted, + size: 48, + ), ), - )), + ), + ), ); }, ), @@ -681,9 +773,13 @@ class _MangaReaderPageState extends State { final tokens = context.tokens; final palette = AppThemeService.currentPalette.value; final hasNext = _currentChapterIndex > 0; - final nextChapter = hasNext ? widget.chapters[_currentChapterIndex - 1] : null; + final nextChapter = hasNext + ? widget.chapters[_currentChapterIndex - 1] + : null; final nextTitle = nextChapter != null - ? (nextChapter.name.isNotEmpty ? nextChapter.name : 'Chapter ${nextChapter.number}') + ? (nextChapter.name.isNotEmpty + ? nextChapter.name + : 'Chapter ${nextChapter.number}') : 'You are all caught up!'; return Container( @@ -712,7 +808,9 @@ class _MangaReaderPageState extends State { ), child: Text( hasNext ? 'UP NEXT' : 'CHAPTER FINISHED', - style: ZplayType.caption.copyWith(weight: FontWeight.w700).toStyle(color: palette.primaryColor), + style: ZplayType.caption + .copyWith(weight: FontWeight.w700) + .toStyle(color: palette.primaryColor), ), ), const SizedBox(height: 12), @@ -721,7 +819,9 @@ class _MangaReaderPageState extends State { textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, - style: ZplayType.title.copyWith(weight: FontWeight.w700).toStyle(color: tokens.textPrimary), + style: ZplayType.title + .copyWith(weight: FontWeight.w700) + .toStyle(color: tokens.textPrimary), ), const SizedBox(height: 6), Text( @@ -732,26 +832,48 @@ class _MangaReaderPageState extends State { if (hasNext) ElevatedButton.icon( onPressed: _nextChapter, - icon: Icon(Icons.skip_next_rounded, color: tokens.textPrimary, size: 20), + icon: Icon( + Icons.skip_next_rounded, + color: tokens.textPrimary, + size: 20, + ), label: Text( 'Read Next Chapter', - style: ZplayType.subtitle.copyWith(weight: FontWeight.w700).toStyle(color: tokens.textPrimary), + style: ZplayType.subtitle + .copyWith(weight: FontWeight.w700) + .toStyle(color: tokens.textPrimary), ), style: ElevatedButton.styleFrom( backgroundColor: palette.primaryColor, - padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14), - shape: const RoundedRectangleBorder(borderRadius: ZplayRadius.mdAll), + padding: const EdgeInsets.symmetric( + horizontal: 28, + vertical: 14, + ), + shape: const RoundedRectangleBorder( + borderRadius: ZplayRadius.mdAll, + ), ), ) else OutlinedButton.icon( onPressed: () => Navigator.of(context).pop(), - icon: Icon(Icons.check_circle_outline_rounded, color: tokens.success), - label: Text('Back to Manga Details', style: ZplayType.label.toStyle(color: tokens.textPrimary)), + icon: Icon( + Icons.check_circle_outline_rounded, + color: tokens.success, + ), + label: Text( + 'Back to Manga Details', + style: ZplayType.label.toStyle(color: tokens.textPrimary), + ), style: OutlinedButton.styleFrom( side: tokens.hairlineStrong, - shape: const RoundedRectangleBorder(borderRadius: ZplayRadius.mdAll), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: const RoundedRectangleBorder( + borderRadius: ZplayRadius.mdAll, + ), + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), ), ), ], @@ -760,17 +882,18 @@ class _MangaReaderPageState extends State { } // 4. Page Deck Preview Section (Placed DIRECTLY ABOVE the slider) - Widget _buildPageDeckSection(bool isMobile, bool isVerySmall, AppThemePalette palette) { + Widget _buildPageDeckSection( + bool isMobile, + bool isVerySmall, + AppThemePalette palette, + ) { final tokens = context.tokens; final cardWidth = isVerySmall ? 54.0 : (isMobile ? 62.0 : 74.0); final cardHeight = isVerySmall ? 78.0 : (isMobile ? 88.0 : 104.0); return Container( margin: const EdgeInsets.only(bottom: 6), - padding: EdgeInsets.symmetric( - horizontal: isMobile ? 8 : 14, - vertical: 8, - ), + padding: EdgeInsets.symmetric(horizontal: isMobile ? 8 : 14, vertical: 8), decoration: BoxDecoration( color: tokens.surfaceOverlay.withValues(alpha: 0.85), borderRadius: ZplayRadius.mdAll, @@ -790,7 +913,11 @@ class _MangaReaderPageState extends State { Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.view_carousel_rounded, color: palette.primaryColor, size: 14), + Icon( + Icons.view_carousel_rounded, + color: palette.primaryColor, + size: 14, + ), const SizedBox(width: 6), Text( 'Page Deck Preview', @@ -801,7 +928,10 @@ class _MangaReaderPageState extends State { ], ), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), decoration: BoxDecoration( color: palette.primaryColor.withValues(alpha: 0.18), borderRadius: ZplayRadius.xsAll, @@ -811,7 +941,9 @@ class _MangaReaderPageState extends State { ), child: Text( 'Page ${_currentPageIndex + 1} of ${_pageUrls.length}', - style: ZplayType.caption.copyWith(weight: FontWeight.w700).toStyle(color: palette.primaryColor), + style: ZplayType.caption + .copyWith(weight: FontWeight.w700) + .toStyle(color: palette.primaryColor), ), ), ], @@ -837,13 +969,17 @@ class _MangaReaderPageState extends State { color: tokens.surface, borderRadius: ZplayRadius.smAll, border: Border.all( - color: isCurrent ? palette.primaryColor : tokens.borderStrong, + color: isCurrent + ? palette.primaryColor + : tokens.borderStrong, width: isCurrent ? 2.2 : 1.0, ), boxShadow: isCurrent ? [ BoxShadow( - color: palette.primaryColor.withValues(alpha: 0.45), + color: palette.primaryColor.withValues( + alpha: 0.45, + ), blurRadius: 10, offset: const Offset(0, 2), ), @@ -860,10 +996,16 @@ class _MangaReaderPageState extends State { cacheManager: AppImageCache.manager, fit: BoxFit.cover, memCacheWidth: 140, - placeholder: (_, __) => Container(color: tokens.surface), + placeholder: (_, __) => + Container(color: tokens.surface), errorWidget: (_, __, ___) => Center( - child: Icon(Icons.broken_image_rounded, color: tokens.textDisabled, size: 18), - )), + child: Icon( + Icons.broken_image_rounded, + color: tokens.textDisabled, + size: 18, + ), + ), + ), // Bottom gradient overlay Positioned( left: 0, @@ -890,14 +1032,21 @@ class _MangaReaderPageState extends State { right: 0, child: Center( child: Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 1, + ), decoration: BoxDecoration( - color: isCurrent ? palette.primaryColor : Colors.black.withValues(alpha: 0.7), + color: isCurrent + ? palette.primaryColor + : Colors.black.withValues(alpha: 0.7), borderRadius: ZplayRadius.xsAll, ), child: Text( '${index + 1}', - style: ZplayType.overline.copyWith(weight: FontWeight.w700).toStyle(color: tokens.textPrimary), + style: ZplayType.overline + .copyWith(weight: FontWeight.w700) + .toStyle(color: tokens.textPrimary), ), ), ), @@ -929,7 +1078,12 @@ class _MangaReaderPageState extends State { // the bar. The wash fades to nothing by its underside, so the page meets it // without a seam. return Container( - padding: EdgeInsets.only(top: 12 + topInset, bottom: 12, left: 16, right: 16), + padding: EdgeInsets.only( + top: 12 + topInset, + bottom: 12, + left: 16, + right: 16, + ), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, @@ -1040,14 +1194,21 @@ class _MangaReaderPageState extends State { child: SliderTheme( data: SliderThemeData( activeTrackColor: palette.primaryColor, - inactiveTrackColor: tokens.textPrimary.withValues(alpha: ZplayOpacity.overlayHover), + inactiveTrackColor: tokens.textPrimary.withValues( + alpha: ZplayOpacity.overlayHover, + ), thumbColor: palette.primaryColor, overlayColor: palette.primaryColor.withValues(alpha: 0.2), trackHeight: 3.5, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6.5), + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.5, + ), ), child: Slider( - value: _currentPageIndex.toDouble().clamp(0.0, (_pageUrls.length - 1).toDouble()), + value: _currentPageIndex.toDouble().clamp( + 0.0, + (_pageUrls.length - 1).toDouble(), + ), min: 0.0, max: (_pageUrls.length - 1).toDouble(), divisions: _pageUrls.length > 1 ? _pageUrls.length - 1 : 1, @@ -1068,7 +1229,9 @@ class _MangaReaderPageState extends State { semanticLabel: 'Previous Chapter', iconSize: 24, tapTarget: tapTarget, - onTap: _currentChapterIndex < widget.chapters.length - 1 ? _prevChapter : null, + onTap: _currentChapterIndex < widget.chapters.length - 1 + ? _prevChapter + : null, ), // Center Cluster: Zoom Controls + Page Badge @@ -1100,7 +1263,9 @@ class _MangaReaderPageState extends State { height: tapTarget, child: Center( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), + padding: const EdgeInsets.symmetric( + horizontal: 6, + ), child: Text( '${(_currentZoom * 100).round()}%', style: ZplayType.caption @@ -1108,7 +1273,9 @@ class _MangaReaderPageState extends State { .toStyle( color: (_currentZoom - 1.0).abs() > 0.03 ? palette.primaryColor - : (state.highlighted ? tokens.textPrimary : tokens.textEmphasis), + : (state.highlighted + ? tokens.textPrimary + : tokens.textEmphasis), ), ), ), @@ -1141,7 +1308,10 @@ class _MangaReaderPageState extends State { child: Text( 'Page ${_currentPageIndex + 1} / ${_pageUrls.isNotEmpty ? _pageUrls.length : "?"}', style: ZplayType.caption - .copyWith(size: isVerySmall ? 11 : 12.5, weight: FontWeight.w700) + .copyWith( + size: isVerySmall ? 11 : 12.5, + weight: FontWeight.w700, + ) .toStyle(color: tokens.textPrimary), ), ), @@ -1239,8 +1409,10 @@ class _MangaChromeAction extends StatelessWidget { color: disabled ? tokens.textDisabled : accent - ? tokens.accent - : (state.highlighted ? tokens.textPrimary : tokens.textEmphasis), + ? tokens.accent + : (state.highlighted + ? tokens.textPrimary + : tokens.textEmphasis), shadows: const [_glyphShadow], ), ), diff --git a/lib/pages/music/music_page.dart b/lib/pages/music/music_page.dart index b96b87fd..34af4ac7 100644 --- a/lib/pages/music/music_page.dart +++ b/lib/pages/music/music_page.dart @@ -38,12 +38,12 @@ enum MusicView { home, search, browse, radio, library } extension MusicViewX on MusicView { String get label => switch (this) { - MusicView.home => 'Home', - MusicView.search => 'Search', - MusicView.browse => 'Browse', - MusicView.radio => 'Radio', - MusicView.library => 'Library', - }; + MusicView.home => 'Home', + MusicView.search => 'Search', + MusicView.browse => 'Browse', + MusicView.radio => 'Radio', + MusicView.library => 'Library', + }; } /// Built once: the labels are constants, and rebuilding them on every switch @@ -88,7 +88,8 @@ class MusicPage extends StatefulWidget { class _MusicPageState extends State { final MusicService _musicService = MusicService.instance; - final MusicPlayerController _playerController = MusicPlayerController.instance; + final MusicPlayerController _playerController = + MusicPlayerController.instance; final MusicLibraryService _libraryService = MusicLibraryService.instance; final TextEditingController _searchController = TextEditingController(); @@ -364,11 +365,7 @@ class _MusicPageState extends State { ), title: Row( children: [ - Icon( - Icons.playlist_add_rounded, - color: tokens.accent, - size: 26, - ), + Icon(Icons.playlist_add_rounded, color: tokens.accent, size: 26), const SizedBox(width: ZplaySpacing.s8), Text( 'New Playlist', @@ -391,7 +388,8 @@ class _MusicPageState extends State { memCacheWidth: 120, width: 40, height: 40, - fit: BoxFit.cover), + fit: BoxFit.cover, + ), ), const SizedBox(width: ZplaySpacing.s12), Expanded( @@ -400,13 +398,17 @@ class _MusicPageState extends State { children: [ Text( initialTrack.title, - style: ZplayType.label.toStyle(color: tokens.textPrimary), + style: ZplayType.label.toStyle( + color: tokens.textPrimary, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( initialTrack.artist, - style: ZplayType.caption.toStyle(color: tokens.textSecondary), + style: ZplayType.caption.toStyle( + color: tokens.textSecondary, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -496,7 +498,8 @@ class _MusicPageState extends State { memCacheWidth: 156, width: 52, height: 52, - fit: BoxFit.cover), + fit: BoxFit.cover, + ), ), const SizedBox(width: ZplaySpacing.s12), Expanded( @@ -505,14 +508,18 @@ class _MusicPageState extends State { children: [ Text( track.title, - style: ZplayType.subtitle.toStyle(color: tokens.textPrimary), + style: ZplayType.subtitle.toStyle( + color: tokens.textPrimary, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: ZplaySpacing.s2), Text( track.artist, - style: ZplayType.label.toStyle(color: tokens.textSecondary), + style: ZplayType.label.toStyle( + color: tokens.textSecondary, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -533,18 +540,24 @@ class _MusicPageState extends State { // Quick Offline Download Action Builder( builder: (context) { - final isDownloaded = MusicDownloadService.instance.isDownloaded(track.id); - final isQueued = MusicDownloadService.instance.isQueued(track.id); + final isDownloaded = MusicDownloadService.instance + .isDownloaded(track.id); + final isQueued = MusicDownloadService.instance.isQueued( + track.id, + ); return FocusableInkWell( onTap: () async { Navigator.pop(ctx); if (isDownloaded) { - await MusicDownloadService.instance.deleteDownloadedTrack(track.id); + await MusicDownloadService.instance + .deleteDownloadedTrack(track.id); _showToast('Removed "${track.title}" from downloads'); } else if (!isQueued) { MusicDownloadService.instance.queueTrack(track); - _showToast('Added "${track.title}" to download queue'); + _showToast( + 'Added "${track.title}" to download queue', + ); } }, borderRadius: ZplayRadius.mdAll, @@ -558,7 +571,9 @@ class _MusicPageState extends State { // wash stays and the border it wore - two lines // around one row, in two different tokens - does not. color: isDownloaded - ? tokens.info.withValues(alpha: ZplayOpacity.overlayHover) + ? tokens.info.withValues( + alpha: ZplayOpacity.overlayHover, + ) : tokens.surfaceRaised, borderRadius: ZplayRadius.mdAll, ), @@ -567,8 +582,12 @@ class _MusicPageState extends State { Icon( isDownloaded ? Icons.download_done_rounded - : (isQueued ? Icons.hourglass_top_rounded : Icons.download_rounded), - color: isDownloaded ? tokens.info : tokens.textPrimary, + : (isQueued + ? Icons.hourglass_top_rounded + : Icons.download_rounded), + color: isDownloaded + ? tokens.info + : tokens.textPrimary, size: 20, ), const SizedBox(width: ZplaySpacing.s12), @@ -576,14 +595,22 @@ class _MusicPageState extends State { child: Text( isDownloaded ? 'Downloaded Offline (Tap to Remove)' - : (isQueued ? 'Downloading / Queued...' : 'Download Track Offline'), + : (isQueued + ? 'Downloading / Queued...' + : 'Download Track Offline'), style: ZplayType.label.toStyle( - color: isDownloaded ? tokens.info : tokens.textPrimary, + color: isDownloaded + ? tokens.info + : tokens.textPrimary, ), ), ), if (isDownloaded) - Icon(Icons.delete_outline_rounded, color: tokens.danger, size: 18), + Icon( + Icons.delete_outline_rounded, + color: tokens.danger, + size: 18, + ), ], ), ), @@ -612,7 +639,9 @@ class _MusicPageState extends State { const SizedBox(height: ZplaySpacing.s12), if (playlists.isEmpty) Padding( - padding: const EdgeInsets.symmetric(vertical: ZplaySpacing.s24), + padding: const EdgeInsets.symmetric( + vertical: ZplaySpacing.s24, + ), child: Center( child: Column( children: [ @@ -624,7 +653,9 @@ class _MusicPageState extends State { const SizedBox(height: ZplaySpacing.s8), Text( 'No custom playlists yet', - style: ZplayType.body.toStyle(color: tokens.textEmphasis), + style: ZplayType.body.toStyle( + color: tokens.textEmphasis, + ), ), const SizedBox(height: ZplaySpacing.s12), PillButton( @@ -644,10 +675,13 @@ class _MusicPageState extends State { child: ListView.separated( shrinkWrap: true, itemCount: playlists.length, - separatorBuilder: (_, __) => const SizedBox(height: ZplaySpacing.s8), + separatorBuilder: (_, __) => + const SizedBox(height: ZplaySpacing.s8), itemBuilder: (context, index) { final pl = playlists[index]; - final inPlaylist = pl.tracks.any((t) => t.id == track.id); + final inPlaylist = pl.tracks.any( + (t) => t.id == track.id, + ); return FocusableInkWell( borderRadius: ZplayRadius.mdAll, onTap: () async { @@ -692,17 +726,22 @@ class _MusicPageState extends State { const SizedBox(width: ZplaySpacing.s12), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.start, children: [ Text( pl.title, - style: ZplayType.subtitle.toStyle(color: tokens.textPrimary), + style: ZplayType.subtitle.toStyle( + color: tokens.textPrimary, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( '${pl.tracks.length} tracks', - style: ZplayType.bodySmall.toStyle(color: tokens.textSecondary), + style: ZplayType.bodySmall.toStyle( + color: tokens.textSecondary, + ), ), ], ), @@ -750,16 +789,16 @@ class _MusicPageState extends State { /// float 8 dp inside the cards it named or sit at a second, larger edge - and /// the rails were a third. One gutter instead of three. static Widget _gutter(Widget child) => Padding( - padding: const EdgeInsets.symmetric(horizontal: ZplaySpacing.s16), - child: child, - ); + padding: const EdgeInsets.symmetric(horizontal: ZplaySpacing.s16), + child: child, + ); /// The padding a view's scroll list carries: the sticky chrome above it and /// the page's closing gap, with the horizontal edge left to each block. EdgeInsets _viewListPadding(BuildContext context) => EdgeInsets.only( - top: _contentTopPadding(context), - bottom: ZplaySpacing.s24, - ); + top: _contentTopPadding(context), + bottom: ZplaySpacing.s24, + ); @override Widget build(BuildContext context) { @@ -775,9 +814,7 @@ class _MusicPageState extends State { children: [ // Dynamic Ambient Background Atmosphere if (MusicSettings.enableAmbientLights.value) - const Positioned.fill( - child: AnimatedAmbientBackground(), - ), + const Positioned.fill(child: AnimatedAmbientBackground()), // Page content area. The shell owns navigation and the now playing // bar now, so this page is its own content with its own header over @@ -845,7 +882,8 @@ class _MusicPageState extends State { child: _MusicArtistDetailModal( details: _activeArtistModal!, onClose: () => setState(() => _activeArtistModal = null), - onPlayTrack: (t, queue) => _playerController.playTrack(t, playlistQueue: queue), + onPlayTrack: (t, queue) => + _playerController.playTrack(t, playlistQueue: queue), onAddToPlaylist: _showAddToPlaylistMenu, onOpenAlbum: _openAlbumModal, ), @@ -857,7 +895,8 @@ class _MusicPageState extends State { child: _MusicAlbumDetailModal( details: _activeAlbumModal!, onClose: () => setState(() => _activeAlbumModal = null), - onPlayTrack: (t, queue) => _playerController.playTrack(t, playlistQueue: queue), + onPlayTrack: (t, queue) => + _playerController.playTrack(t, playlistQueue: queue), onAddToPlaylist: _showAddToPlaylistMenu, ), ), @@ -867,8 +906,10 @@ class _MusicPageState extends State { Positioned.fill( child: _MusicCuratedPlaylistDetailModal( details: _activeCuratedPlaylistModal!, - onClose: () => setState(() => _activeCuratedPlaylistModal = null), - onPlayTrack: (t, queue) => _playerController.playTrack(t, playlistQueue: queue), + onClose: () => + setState(() => _activeCuratedPlaylistModal = null), + onPlayTrack: (t, queue) => + _playerController.playTrack(t, playlistQueue: queue), onAddToPlaylist: _showAddToPlaylistMenu, ), ), @@ -878,10 +919,15 @@ class _MusicPageState extends State { Positioned.fill( child: _MusicUserPlaylistDetailModal( playlist: _activeUserPlaylistModal!, - onClose: () => setState(() => _activeUserPlaylistModal = null), - onPlayTrack: (t, queue) => _playerController.playTrack(t, playlistQueue: queue), + onClose: () => + setState(() => _activeUserPlaylistModal = null), + onPlayTrack: (t, queue) => + _playerController.playTrack(t, playlistQueue: queue), onRemoveTrack: (trackId) async { - await _libraryService.removeTrackFromPlaylist(_activeUserPlaylistModal!.id, trackId); + await _libraryService.removeTrackFromPlaylist( + _activeUserPlaylistModal!.id, + trackId, + ); setState(() { final updated = _libraryService.userPlaylists.firstWhere( (p) => p.id == _activeUserPlaylistModal!.id, @@ -906,7 +952,8 @@ class _MusicPageState extends State { Positioned.fill( child: _MusicDownloadedTracksModal( onClose: () => setState(() => _showDownloadsModal = false), - onPlayTrack: (t, queue) => _playerController.playTrack(t, playlistQueue: queue), + onPlayTrack: (t, queue) => + _playerController.playTrack(t, playlistQueue: queue), onAddToPlaylist: _showAddToPlaylistMenu, ), ), @@ -921,7 +968,9 @@ class _MusicPageState extends State { ), onToggleSave: () { if (_playerController.currentTrack != null) { - _libraryService.toggleLikeTrack(_playerController.currentTrack!); + _libraryService.toggleLikeTrack( + _playerController.currentTrack!, + ); } }, onCollapse: () => setState(() => _isPlayerExpanded = false), @@ -988,9 +1037,7 @@ class _MusicPageState extends State { final tokens = context.tokens; if (_isLoading) { - return Center( - child: CircularProgressIndicator(color: tokens.accent), - ); + return Center(child: CircularProgressIndicator(color: tokens.accent)); } // Exhaustive, so a view cannot be added without a body to show for it. The @@ -1023,14 +1070,18 @@ class _MusicPageState extends State { top: _contentTopPadding(context), bottom: ZplaySpacing.s24, ), - physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), children: [ if (MusicSettings.enableSpotlight.value && _heroTrack != null) _MusicHeroBillboard( track: _heroTrack!, onPlayTap: () => _playerController.playTrack( _heroTrack!, - playlistQueue: _sections.values.isNotEmpty ? _sections.values.first : null, + playlistQueue: _sections.values.isNotEmpty + ? _sections.values.first + : null, ), onSaveTap: () { _libraryService.toggleLikeTrack(_heroTrack!); @@ -1068,10 +1119,7 @@ class _MusicPageState extends State { onPlaylistTap: (pl) => _openCuratedPlaylistModal(pl.id), ), for (final entry in _sections.entries) - _MusicCategorySlider( - title: entry.key, - tracks: entry.value, - ), + _MusicCategorySlider(title: entry.key, tracks: entry.value), ], ), ); @@ -1146,7 +1194,9 @@ class _MusicPageState extends State { ? 'Search to find tracks, artists, albums and playlists' : 'No results for "$_activeQuery"', textAlign: TextAlign.center, - style: ZplayType.subtitle.toStyle(color: tokens.textPrimary), + style: ZplayType.subtitle.toStyle( + color: tokens.textPrimary, + ), ), ], ), @@ -1154,7 +1204,8 @@ class _MusicPageState extends State { ), ) else ...[ - if ((_selectedFilter == 'All' || _selectedFilter.startsWith('Tracks')) && + if ((_selectedFilter == 'All' || + _selectedFilter.startsWith('Tracks')) && _searchData.tracks.isNotEmpty) ...[ SectionHeader( title: 'Songs', @@ -1166,12 +1217,14 @@ class _MusicPageState extends State { shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: _searchData.tracks.length.clamp(0, 15), - separatorBuilder: (_, __) => const SizedBox(height: ZplaySpacing.s8), + separatorBuilder: (_, __) => + const SizedBox(height: ZplaySpacing.s8), itemBuilder: (context, index) { final track = _searchData.tracks[index]; return _MusicTrackRow( track: track, - isPlaying: _playerController.currentTrack?.id == track.id && + isPlaying: + _playerController.currentTrack?.id == track.id && _playerController.isPlaying, isCurrent: _playerController.currentTrack?.id == track.id, onTap: () => _playerController.playTrack( @@ -1185,21 +1238,22 @@ class _MusicPageState extends State { ), const SizedBox(height: ZplaySpacing.s32), ], - if ((_selectedFilter == 'All' || _selectedFilter.startsWith('Artists')) && + if ((_selectedFilter == 'All' || + _selectedFilter.startsWith('Artists')) && _searchData.artists.isNotEmpty) ...[ - SectionHeader( - title: 'Artists', - count: _searchData.artists.length, - ), + SectionHeader(title: 'Artists', count: _searchData.artists.length), const SizedBox(height: ZplaySpacing.s12), SizedBox( height: 130, child: ListView.separated( scrollDirection: Axis.horizontal, physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: ZplaySpacing.s16), + padding: const EdgeInsets.symmetric( + horizontal: ZplaySpacing.s16, + ), itemCount: _searchData.artists.length, - separatorBuilder: (_, __) => const SizedBox(width: ZplaySpacing.s16), + separatorBuilder: (_, __) => + const SizedBox(width: ZplaySpacing.s16), itemBuilder: (context, index) { final artist = _searchData.artists[index]; return _MusicArtistTile( @@ -1211,7 +1265,8 @@ class _MusicPageState extends State { ), const SizedBox(height: ZplaySpacing.s32), ], - if ((_selectedFilter == 'All' || _selectedFilter.startsWith('Albums')) && + if ((_selectedFilter == 'All' || + _selectedFilter.startsWith('Albums')) && _searchData.albums.isNotEmpty) ...[ SectionHeader( title: 'Albums', @@ -1223,9 +1278,10 @@ class _MusicPageState extends State { shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: (MediaQuery.sizeOf(context).width / sizing.cardWidth) - .floor() - .clamp(2, 6), + crossAxisCount: + (MediaQuery.sizeOf(context).width / sizing.cardWidth) + .floor() + .clamp(2, 6), mainAxisSpacing: ZplaySpacing.s20, crossAxisSpacing: ZplaySpacing.s16, childAspectRatio: sizing.cardWidth / sizing.totalHeight, @@ -1244,7 +1300,8 @@ class _MusicPageState extends State { ), const SizedBox(height: ZplaySpacing.s32), ], - if ((_selectedFilter == 'All' || _selectedFilter.startsWith('Playlists')) && + if ((_selectedFilter == 'All' || + _selectedFilter.startsWith('Playlists')) && _searchData.playlists.isNotEmpty) ...[ SectionHeader( title: 'Playlists', @@ -1256,9 +1313,10 @@ class _MusicPageState extends State { shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: (MediaQuery.sizeOf(context).width / sizing.cardWidth) - .floor() - .clamp(2, 6), + crossAxisCount: + (MediaQuery.sizeOf(context).width / sizing.cardWidth) + .floor() + .clamp(2, 6), mainAxisSpacing: ZplaySpacing.s20, crossAxisSpacing: ZplaySpacing.s16, childAspectRatio: sizing.cardWidth / sizing.totalHeight, @@ -1334,7 +1392,9 @@ class _MusicPageState extends State { alignment: Alignment.bottomLeft, child: Text( g['title'] as String, - style: ZplayType.title.toStyle(color: tokens.textPrimary), + style: ZplayType.title.toStyle( + color: tokens.textPrimary, + ), ), ), ), @@ -1402,7 +1462,11 @@ class _MusicPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Icon(Icons.radio_rounded, color: tokens.accent, size: 28), + Icon( + Icons.radio_rounded, + color: tokens.accent, + size: 28, + ), const SizedBox(height: ZplaySpacing.s4), // Flexible, because the grid fixes the tile's height at // `width / 1.5` and a three-line station name overflowed @@ -1413,8 +1477,9 @@ class _MusicPageState extends State { station['name'] as String, maxLines: 2, overflow: TextOverflow.ellipsis, - style: - ZplayType.subtitle.toStyle(color: tokens.textPrimary), + style: ZplayType.subtitle.toStyle( + color: tokens.textPrimary, + ), ), ), ], @@ -1503,12 +1568,16 @@ class _MusicPageState extends State { children: [ Text( 'Liked Songs', - style: ZplayType.title.toStyle(color: tokens.textPrimary), + style: ZplayType.title.toStyle( + color: tokens.textPrimary, + ), ), const SizedBox(height: ZplaySpacing.s4), Text( '${liked.length} favourite tracks', - style: ZplayType.bodySmall.toStyle(color: tokens.textSecondary), + style: ZplayType.bodySmall.toStyle( + color: tokens.textSecondary, + ), ), ], ), @@ -1544,7 +1613,8 @@ class _MusicPageState extends State { builder: (context) { final downloaded = MusicDownloadService.instance.downloadedTracks; final queue = MusicDownloadService.instance.queue; - final totalBytes = MusicDownloadService.instance.totalDownloadedSizeBytes; + final totalBytes = + MusicDownloadService.instance.totalDownloadedSizeBytes; final sizeMb = (totalBytes / (1024 * 1024)).toStringAsFixed(1); return FocusableCard( @@ -1591,7 +1661,9 @@ class _MusicPageState extends State { Flexible( child: Text( 'Downloaded Songs', - style: ZplayType.title.toStyle(color: tokens.textPrimary), + style: ZplayType.title.toStyle( + color: tokens.textPrimary, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -1600,7 +1672,9 @@ class _MusicPageState extends State { const SizedBox(width: ZplaySpacing.s8), Text( 'Queue (${queue.length})', - style: ZplayType.overline.toStyle(color: tokens.textMuted), + style: ZplayType.overline.toStyle( + color: tokens.textMuted, + ), ), ], ], @@ -1608,7 +1682,9 @@ class _MusicPageState extends State { const SizedBox(height: ZplaySpacing.s4), Text( '${downloaded.length} offline tracks • $sizeMb MB', - style: ZplayType.bodySmall.toStyle(color: tokens.textSecondary), + style: ZplayType.bodySmall.toStyle( + color: tokens.textSecondary, + ), ), ], ), @@ -1690,13 +1766,17 @@ class _MusicPageState extends State { const SizedBox(height: ZplaySpacing.s8), Text( pl.title, - style: ZplayType.subtitle.toStyle(color: tokens.textPrimary), + style: ZplayType.subtitle.toStyle( + color: tokens.textPrimary, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( '${pl.tracks.length} tracks', - style: ZplayType.bodySmall.toStyle(color: tokens.textSecondary), + style: ZplayType.bodySmall.toStyle( + color: tokens.textSecondary, + ), ), ], ), @@ -1712,22 +1792,28 @@ class _MusicPageState extends State { // Recent History Section if (recent.isNotEmpty) ...[ - SectionHeader(title: 'Recently Played', count: recent.length.clamp(0, 10)), + SectionHeader( + title: 'Recently Played', + count: recent.length.clamp(0, 10), + ), const SizedBox(height: ZplaySpacing.s12), _gutter( ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: recent.length.clamp(0, 10), - separatorBuilder: (_, __) => const SizedBox(height: ZplaySpacing.s8), + separatorBuilder: (_, __) => + const SizedBox(height: ZplaySpacing.s8), itemBuilder: (context, index) { final track = recent[index]; return _MusicTrackRow( track: track, - isPlaying: _playerController.currentTrack?.id == track.id && + isPlaying: + _playerController.currentTrack?.id == track.id && _playerController.isPlaying, isCurrent: _playerController.currentTrack?.id == track.id, - onTap: () => _playerController.playTrack(track, playlistQueue: recent), + onTap: () => + _playerController.playTrack(track, playlistQueue: recent), onMoreTap: () => _showAddToPlaylistMenu(track), ); }, @@ -1757,10 +1843,12 @@ class _MusicHorizontalScrollSection extends StatefulWidget { }); @override - State<_MusicHorizontalScrollSection> createState() => _MusicHorizontalScrollSectionState(); + State<_MusicHorizontalScrollSection> createState() => + _MusicHorizontalScrollSectionState(); } -class _MusicHorizontalScrollSectionState extends State<_MusicHorizontalScrollSection> { +class _MusicHorizontalScrollSectionState + extends State<_MusicHorizontalScrollSection> { late final ScrollController _controller; bool _canScrollLeft = false; bool _canScrollRight = true; @@ -1786,7 +1874,8 @@ class _MusicHorizontalScrollSectionState extends State<_MusicHorizontalScrollSec void _updateScrollButtons() { if (!_controller.hasClients) return; final canLeft = _controller.position.pixels > 5; - final canRight = _controller.position.pixels < _controller.position.maxScrollExtent - 5; + final canRight = + _controller.position.pixels < _controller.position.maxScrollExtent - 5; if (canLeft != _canScrollLeft || canRight != _canScrollRight) { setState(() { _canScrollLeft = canLeft; @@ -1799,8 +1888,10 @@ class _MusicHorizontalScrollSectionState extends State<_MusicHorizontalScrollSec if (!_controller.hasClients) return; final viewportWidth = _controller.position.viewportDimension; final scrollAmount = viewportWidth * 0.75 * multiplier; - final target = (_controller.position.pixels + scrollAmount) - .clamp(0.0, _controller.position.maxScrollExtent); + final target = (_controller.position.pixels + scrollAmount).clamp( + 0.0, + _controller.position.maxScrollExtent, + ); _controller.animateTo( target, @@ -1850,7 +1941,8 @@ class _MusicHorizontalScrollSectionState extends State<_MusicHorizontalScrollSec horizontal: ZplaySpacing.s16, ), itemCount: widget.itemCount, - separatorBuilder: (_, __) => const SizedBox(width: ZplaySpacing.s16), + separatorBuilder: (_, __) => + const SizedBox(width: ZplaySpacing.s16), itemBuilder: widget.itemBuilder, ), @@ -2001,10 +2093,7 @@ class _MusicChrome extends StatelessWidget { ), ), ), - Column( - mainAxisSize: MainAxisSize.min, - children: [header, band], - ), + Column(mainAxisSize: MainAxisSize.min, children: [header, band]), ], ), ); @@ -2089,7 +2178,11 @@ class _MusicTopHeader extends StatelessWidget { ), child: Row( children: [ - Icon(Icons.search_rounded, color: tokens.textSecondary, size: isMobile ? 18 : 20), + Icon( + Icons.search_rounded, + color: tokens.textSecondary, + size: isMobile ? 18 : 20, + ), const SizedBox(width: ZplaySpacing.s8), Expanded( child: TextField( @@ -2101,9 +2194,12 @@ class _MusicTopHeader extends StatelessWidget { decoration: InputDecoration( hintText: isVeryNarrow ? 'Search…' - : (isMobile ? 'Search music…' : 'Search songs, artists, albums, playlists...'), - hintStyle: (isMobile ? ZplayType.bodySmall : ZplayType.label) - .toStyle(color: tokens.textMuted), + : (isMobile + ? 'Search music…' + : 'Search songs, artists, albums, playlists...'), + hintStyle: + (isMobile ? ZplayType.bodySmall : ZplayType.label) + .toStyle(color: tokens.textMuted), border: InputBorder.none, isDense: true, ), @@ -2113,7 +2209,11 @@ class _MusicTopHeader extends StatelessWidget { IconButton( padding: EdgeInsets.zero, constraints: const BoxConstraints(), - icon: Icon(Icons.close_rounded, color: tokens.textSecondary, size: 16), + icon: Icon( + Icons.close_rounded, + color: tokens.textSecondary, + size: 16, + ), onPressed: onClearSearch, ), ], @@ -2136,17 +2236,16 @@ class _MusicTopHeader extends StatelessWidget { onPressed: () { Navigator.push( context, - MaterialPageRoute(builder: (_) => const MusicPlayerStudioPage()), + MaterialPageRoute( + builder: (_) => const MusicPlayerStudioPage(), + ), ); }, ), SizedBox(width: isMobile ? ZplaySpacing.s2 : ZplaySpacing.s4), IconButton( tooltip: 'Music Atmosphere Settings', - icon: const Icon( - Icons.palette_rounded, - shadows: [_iconShadow], - ), + icon: const Icon(Icons.palette_rounded, shadows: [_iconShadow]), color: tokens.textEmphasis, iconSize: 20, style: rowButton, @@ -2161,10 +2260,7 @@ class _MusicTopHeader extends StatelessWidget { SizedBox(width: isMobile ? ZplaySpacing.s2 : ZplaySpacing.s4), IconButton( tooltip: 'App Settings', - icon: const Icon( - Icons.settings_rounded, - shadows: [_iconShadow], - ), + icon: const Icon(Icons.settings_rounded, shadows: [_iconShadow]), color: tokens.textEmphasis, iconSize: 20, style: rowButton, @@ -2197,8 +2293,7 @@ class _MusicViewBand extends StatelessWidget { // is a second row of the same bar, and a tablet-width window already had // room for the header's s20 gutter. final isMobile = MediaQuery.sizeOf(context).width < 600; - final television = - FormFactorService.of(context) == FormFactor.television; + final television = FormFactorService.of(context) == FormFactor.television; return Padding( padding: EdgeInsets.fromLTRB( @@ -2345,7 +2440,11 @@ class _AudioSourceSelectorButton extends StatelessWidget { color: tokens.accentSubtle, borderRadius: ZplayRadius.mdAll, ), - child: Icon(Icons.tune_rounded, color: tokens.accent, size: 22), + child: Icon( + Icons.tune_rounded, + color: tokens.accent, + size: 22, + ), ), const SizedBox(width: ZplaySpacing.s12), Column( @@ -2353,12 +2452,16 @@ class _AudioSourceSelectorButton extends StatelessWidget { children: [ Text( 'Audio Source & Quality', - style: ZplayType.title.toStyle(color: tokens.textPrimary), + style: ZplayType.title.toStyle( + color: tokens.textPrimary, + ), ), const SizedBox(height: ZplaySpacing.s2), Text( 'Choose your preferred music extraction engine', - style: ZplayType.bodySmall.toStyle(color: tokens.textSecondary), + style: ZplayType.bodySmall.toStyle( + color: tokens.textSecondary, + ), ), ], ), @@ -2368,7 +2471,8 @@ class _AudioSourceSelectorButton extends StatelessWidget { _sourceOptionCard( context: context, title: 'FLAC Lossless (Qobuz Hi-Res)', - subtitle: 'Studio master quality up to 24-bit/192kHz with zero compression', + subtitle: + 'Studio master quality up to 24-bit/192kHz with zero compression', icon: Icons.diamond_rounded, isSelected: isFlac, badge: 'LOSSLESS', @@ -2381,7 +2485,8 @@ class _AudioSourceSelectorButton extends StatelessWidget { _sourceOptionCard( context: context, title: 'YouTube Audio', - subtitle: 'High-speed audio extraction with intelligent track & duration matching', + subtitle: + 'High-speed audio extraction with intelligent track & duration matching', icon: Icons.play_circle_fill_rounded, isSelected: !isFlac, badge: 'FAST', @@ -2447,20 +2552,26 @@ class _AudioSourceSelectorButton extends StatelessWidget { Flexible( child: Text( title, - style: ZplayType.subtitle.toStyle(color: tokens.textPrimary), + style: ZplayType.subtitle.toStyle( + color: tokens.textPrimary, + ), ), ), const SizedBox(width: ZplaySpacing.s8), Text( badge, - style: ZplayType.overline.toStyle(color: tokens.textMuted), + style: ZplayType.overline.toStyle( + color: tokens.textMuted, + ), ), ], ), const SizedBox(height: ZplaySpacing.s4), Text( subtitle, - style: ZplayType.bodySmall.toStyle(color: tokens.textSecondary), + style: ZplayType.bodySmall.toStyle( + color: tokens.textSecondary, + ), ), ], ), @@ -2469,7 +2580,11 @@ class _AudioSourceSelectorButton extends StatelessWidget { if (isSelected) Icon(Icons.check_circle_rounded, color: tokens.accent, size: 22) else - Icon(Icons.radio_button_unchecked_rounded, color: tokens.textDisabled, size: 22), + Icon( + Icons.radio_button_unchecked_rounded, + color: tokens.textDisabled, + size: 22, + ), ], ), ), @@ -2504,11 +2619,12 @@ class _MusicHeroBillboard extends StatelessWidget { // label while this track is the one playing; the catalogue carries no bit // depth or sample rate to print a "24-bit / 96kHz" claim from. final player = MusicPlayerController.instance; - final isPlayingThis = track.id.isNotEmpty && player.currentTrack?.id == track.id; + final isPlayingThis = + track.id.isNotEmpty && player.currentTrack?.id == track.id; final overline = isPlayingThis ? (player.isCurrentTrackLossless - ? 'HI-RES LOSSLESS · ${player.currentQualityLabel}' - : player.currentQualityLabel) + ? 'HI-RES LOSSLESS · ${player.currentQualityLabel}' + : player.currentQualityLabel) : 'TOP CHART HIT'; // What there is to say about this track, in the reference's order: who @@ -2541,7 +2657,8 @@ class _MusicHeroBillboard extends StatelessWidget { child: CachedNetworkImage( imageUrl: track.coverUrl, cacheManager: AppImageCache.manager, - fit: BoxFit.cover), + fit: BoxFit.cover, + ), ), ), const SizedBox(width: ZplaySpacing.s20), @@ -2605,7 +2722,9 @@ class _MusicHeroBillboard extends StatelessWidget { minimumSize: Size.square(PillButton.heightFor(context)), ), icon: Icon( - isSaved ? Icons.favorite_rounded : Icons.favorite_border_rounded, + isSaved + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, color: isSaved ? tokens.danger : tokens.textPrimary, ), onPressed: onSaveTap, @@ -2616,7 +2735,10 @@ class _MusicHeroBillboard extends StatelessWidget { backgroundColor: tokens.surfaceRaised, minimumSize: Size.square(PillButton.heightFor(context)), ), - icon: Icon(Icons.playlist_add_rounded, color: tokens.textPrimary), + icon: Icon( + Icons.playlist_add_rounded, + color: tokens.textPrimary, + ), onPressed: onAddToPlaylistTap, ), ], @@ -2665,7 +2787,8 @@ class _MusicArtistTile extends StatelessWidget { memCacheWidth: 240, width: 80, height: 80, - fit: BoxFit.cover), + fit: BoxFit.cover, + ), ), ), const SizedBox(height: ZplaySpacing.s8), @@ -2776,10 +2899,7 @@ class _MusicCategorySlider extends StatelessWidget { final String title; final List tracks; - const _MusicCategorySlider({ - required this.title, - required this.tracks, - }); + const _MusicCategorySlider({required this.title, required this.tracks}); @override Widget build(BuildContext context) { @@ -2857,7 +2977,8 @@ class _MusicCoverCard extends StatelessWidget { memCacheWidth: 435, width: 145, height: 145, - fit: BoxFit.cover), + fit: BoxFit.cover, + ), if (showPlayBadge) Positioned( bottom: 8, @@ -2974,13 +3095,17 @@ class _MusicTrackRow extends StatelessWidget { ? Center( child: isCurrent ? Icon( - isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, + isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, color: tokens.accent, size: 22, ) : Text( '$number', - style: ZplayType.bodySmall.toStyle(color: tokens.textMuted), + style: ZplayType.bodySmall.toStyle( + color: tokens.textMuted, + ), ), ) : Stack( @@ -2989,12 +3114,13 @@ class _MusicTrackRow extends StatelessWidget { ClipRRect( borderRadius: ZplayRadius.smAll, child: CachedNetworkImage( - imageUrl: track.coverUrl, - cacheManager: AppImageCache.manager, - memCacheWidth: 144, - width: ZplaySpacing.s48, - height: ZplaySpacing.s48, - fit: BoxFit.cover), + imageUrl: track.coverUrl, + cacheManager: AppImageCache.manager, + memCacheWidth: 144, + width: ZplaySpacing.s48, + height: ZplaySpacing.s48, + fit: BoxFit.cover, + ), ), if (isCurrent) Container( @@ -3005,7 +3131,9 @@ class _MusicTrackRow extends StatelessWidget { borderRadius: ZplayRadius.smAll, ), child: Icon( - isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, + isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, color: tokens.accent, size: 28, ), @@ -3029,7 +3157,9 @@ class _MusicTrackRow extends StatelessWidget { ), Text( track.artist, - style: ZplayType.bodySmall.toStyle(color: tokens.textSecondary), + style: ZplayType.bodySmall.toStyle( + color: tokens.textSecondary, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -3045,7 +3175,11 @@ class _MusicTrackRow extends StatelessWidget { ), const SizedBox(width: ZplaySpacing.s4), IconButton( - icon: Icon(Icons.more_vert_rounded, color: tokens.textSecondary, size: 20), + icon: Icon( + Icons.more_vert_rounded, + color: tokens.textSecondary, + size: 20, + ), onPressed: onMoreTap, ), ], @@ -3064,8 +3198,10 @@ class _MusicTrackRow extends StatelessWidget { final tokens = context.tokens; final isDownloaded = MusicDownloadService.instance.isDownloaded(track.id); final task = MusicDownloadService.instance.getTask(track.id); - final isDownloading = task != null && - (task.status == MusicDownloadStatus.extracting || task.status == MusicDownloadStatus.downloading); + final isDownloading = + task != null && + (task.status == MusicDownloadStatus.extracting || + task.status == MusicDownloadStatus.downloading); final isQueued = task != null && task.status == MusicDownloadStatus.queued; if (isDownloaded) { @@ -3171,7 +3307,9 @@ class _MusicLyricsDrawer extends StatelessWidget { decoration: BoxDecoration( color: tokens.surfaceOverlay.withValues(alpha: 0.96), borderRadius: isMobile - ? const BorderRadius.vertical(top: Radius.circular(ZplayRadius.lg)) + ? const BorderRadius.vertical( + top: Radius.circular(ZplayRadius.lg), + ) : const BorderRadius.only( topLeft: Radius.circular(ZplayRadius.lg), bottomLeft: Radius.circular(ZplayRadius.lg), @@ -3200,7 +3338,11 @@ class _MusicLyricsDrawer extends StatelessWidget { ), const Spacer(), IconButton( - icon: Icon(Icons.close_rounded, color: tokens.textEmphasis, size: 20), + icon: Icon( + Icons.close_rounded, + color: tokens.textEmphasis, + size: 20, + ), onPressed: onClose, ), ], @@ -3231,13 +3373,18 @@ class _MusicLyricsDrawer extends StatelessWidget { return FocusableCard( onTap: () => playerController.seekTo(line.timestamp), builder: (context, _) => Padding( - padding: const EdgeInsets.symmetric(vertical: ZplaySpacing.s8), + padding: const EdgeInsets.symmetric( + vertical: ZplaySpacing.s8, + ), child: Text( line.text, - style: (isActive ? ZplayType.title : ZplayType.subtitle) - .toStyle( - color: isActive ? tokens.accent : tokens.textSecondary, - ), + style: + (isActive ? ZplayType.title : ZplayType.subtitle) + .toStyle( + color: isActive + ? tokens.accent + : tokens.textSecondary, + ), ), ), ); @@ -3282,7 +3429,9 @@ class _MusicQueueDrawer extends StatelessWidget { decoration: BoxDecoration( color: tokens.surfaceOverlay.withValues(alpha: 0.96), borderRadius: isMobile - ? const BorderRadius.vertical(top: Radius.circular(ZplayRadius.lg)) + ? const BorderRadius.vertical( + top: Radius.circular(ZplayRadius.lg), + ) : const BorderRadius.only( topLeft: Radius.circular(ZplayRadius.lg), bottomLeft: Radius.circular(ZplayRadius.lg), @@ -3294,11 +3443,7 @@ class _MusicQueueDrawer extends StatelessWidget { children: [ Row( children: [ - Icon( - Icons.queue_music_rounded, - color: tokens.accent, - size: 22, - ), + Icon(Icons.queue_music_rounded, color: tokens.accent, size: 22), const SizedBox(width: ZplaySpacing.s8), Text( 'Queue (${queue.length})', @@ -3313,7 +3458,11 @@ class _MusicQueueDrawer extends StatelessWidget { ), const SizedBox(width: ZplaySpacing.s4), IconButton( - icon: Icon(Icons.close_rounded, color: tokens.textEmphasis, size: 20), + icon: Icon( + Icons.close_rounded, + color: tokens.textEmphasis, + size: 20, + ), onPressed: onClose, ), ], @@ -3332,13 +3481,15 @@ class _MusicQueueDrawer extends StatelessWidget { Expanded( child: ListView.separated( itemCount: queue.length, - separatorBuilder: (_, __) => const SizedBox(height: ZplaySpacing.s8), + separatorBuilder: (_, __) => + const SizedBox(height: ZplaySpacing.s8), itemBuilder: (context, index) { final track = queue[index]; final isCurrent = index == currentIndex; return FocusableInkWell( borderRadius: ZplayRadius.smAll, - onTap: () => controller.playTrack(track, playlistQueue: queue), + onTap: () => + controller.playTrack(track, playlistQueue: queue), child: Container( padding: const EdgeInsets.symmetric( horizontal: ZplaySpacing.s8, @@ -3347,7 +3498,9 @@ class _MusicQueueDrawer extends StatelessWidget { decoration: BoxDecoration( // The playing row is the one thing worth a fill in a // queue; every other row is its own cover and text. - color: isCurrent ? tokens.accentSubtle : Colors.transparent, + color: isCurrent + ? tokens.accentSubtle + : Colors.transparent, borderRadius: ZplayRadius.smAll, ), child: Row( @@ -3360,7 +3513,8 @@ class _MusicQueueDrawer extends StatelessWidget { memCacheWidth: 108, width: 36, height: 36, - fit: BoxFit.cover), + fit: BoxFit.cover, + ), ), const SizedBox(width: ZplaySpacing.s12), Expanded( @@ -3370,14 +3524,18 @@ class _MusicQueueDrawer extends StatelessWidget { Text( track.title, style: ZplayType.label.toStyle( - color: isCurrent ? tokens.accent : tokens.textPrimary, + color: isCurrent + ? tokens.accent + : tokens.textPrimary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( track.artist, - style: ZplayType.caption.toStyle(color: tokens.textSecondary), + style: ZplayType.caption.toStyle( + color: tokens.textSecondary, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -3385,8 +3543,13 @@ class _MusicQueueDrawer extends StatelessWidget { ), ), IconButton( - icon: Icon(Icons.close_rounded, color: tokens.textMuted, size: 16), - onPressed: () => controller.removeFromQueue(index), + icon: Icon( + Icons.close_rounded, + color: tokens.textMuted, + size: 16, + ), + onPressed: () => + controller.removeFromQueue(index), ), ], ), @@ -3446,7 +3609,8 @@ class _MusicArtistDetailModal extends StatelessWidget { child: CachedNetworkImage( imageUrl: artist.pictureUrl, cacheManager: AppImageCache.manager, - fit: BoxFit.cover), + fit: BoxFit.cover, + ), ), Positioned.fill( child: DecoratedBox( @@ -3454,10 +3618,7 @@ class _MusicArtistDetailModal extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - tokens.surfaceOverlay, - ], + colors: [Colors.transparent, tokens.surfaceOverlay], ), ), ), @@ -3466,7 +3627,11 @@ class _MusicArtistDetailModal extends StatelessWidget { top: 12, right: 12, child: IconButton( - icon: Icon(Icons.close_rounded, color: tokens.textPrimary, size: 24), + icon: Icon( + Icons.close_rounded, + color: tokens.textPrimary, + size: 24, + ), onPressed: onClose, ), ), @@ -3477,14 +3642,19 @@ class _MusicArtistDetailModal extends StatelessWidget { children: [ Text( artist.name, - style: ZplayType.display.toStyle(color: tokens.textPrimary), + style: ZplayType.display.toStyle( + color: tokens.textPrimary, + ), ), const SizedBox(width: ZplaySpacing.s16), if (details.topTracks.isNotEmpty) PillButton( label: 'Play All', icon: Icons.play_arrow_rounded, - onPressed: () => onPlayTrack(details.topTracks.first, details.topTracks), + onPressed: () => onPlayTrack( + details.topTracks.first, + details.topTracks, + ), ), ], ), @@ -3501,14 +3671,17 @@ class _MusicArtistDetailModal extends StatelessWidget { if (details.topTracks.isNotEmpty) ...[ Text( 'Top Songs', - style: ZplayType.title.toStyle(color: tokens.textPrimary), + style: ZplayType.title.toStyle( + color: tokens.textPrimary, + ), ), const SizedBox(height: ZplaySpacing.s12), ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: details.topTracks.length.clamp(0, 10), - separatorBuilder: (_, __) => const SizedBox(height: ZplaySpacing.s8), + separatorBuilder: (_, __) => + const SizedBox(height: ZplaySpacing.s8), itemBuilder: (context, index) { final track = details.topTracks[index]; return _MusicTrackRow( @@ -3516,7 +3689,8 @@ class _MusicArtistDetailModal extends StatelessWidget { index: index, isPlaying: false, isCurrent: false, - onTap: () => onPlayTrack(track, details.topTracks), + onTap: () => + onPlayTrack(track, details.topTracks), onMoreTap: () => onAddToPlaylist(track), ); }, @@ -3526,7 +3700,9 @@ class _MusicArtistDetailModal extends StatelessWidget { if (details.albums.isNotEmpty) ...[ Text( 'Albums & Discography', - style: ZplayType.title.toStyle(color: tokens.textPrimary), + style: ZplayType.title.toStyle( + color: tokens.textPrimary, + ), ), const SizedBox(height: ZplaySpacing.s12), SizedBox( @@ -3534,7 +3710,8 @@ class _MusicArtistDetailModal extends StatelessWidget { child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: details.albums.length, - separatorBuilder: (_, __) => const SizedBox(width: ZplaySpacing.s12), + separatorBuilder: (_, __) => + const SizedBox(width: ZplaySpacing.s12), itemBuilder: (context, index) { final album = details.albums[index]; return _MusicCoverCard( @@ -3588,8 +3765,8 @@ class _MusicAlbumDetailModal extends StatelessWidget { album.id.isNotEmpty && player.currentTrack?.albumId == album.id; final albumOverline = playingThisAlbum ? (player.isCurrentTrackLossless - ? 'HI-RES LOSSLESS · ${player.currentQualityLabel}' - : player.currentQualityLabel) + ? 'HI-RES LOSSLESS · ${player.currentQualityLabel}' + : player.currentQualityLabel) : 'ALBUM'; return Container( @@ -3626,11 +3803,12 @@ class _MusicAlbumDetailModal extends StatelessWidget { ClipRRect( borderRadius: ZplayRadius.smAll, child: CachedNetworkImage( - imageUrl: album.coverUrl, - cacheManager: AppImageCache.manager, - width: 88, - height: 88, - fit: BoxFit.cover), + imageUrl: album.coverUrl, + cacheManager: AppImageCache.manager, + width: 88, + height: 88, + fit: BoxFit.cover, + ), ), const SizedBox(width: ZplaySpacing.s20), Expanded( @@ -3640,13 +3818,18 @@ class _MusicAlbumDetailModal extends StatelessWidget { children: [ Text( albumOverline, - style: ZplayType.overline.toStyle(color: tokens.accent), + style: ZplayType.overline.toStyle( + color: tokens.accent, + ), ), const SizedBox(height: ZplaySpacing.s4), Text( album.title, - style: (isMobile ? ZplayType.title : ZplayType.titleLarge) - .toStyle(color: tokens.textPrimary), + style: + (isMobile + ? ZplayType.title + : ZplayType.titleLarge) + .toStyle(color: tokens.textPrimary), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -3655,9 +3838,12 @@ class _MusicAlbumDetailModal extends StatelessWidget { [ album.artistName, '${tracks.length} Tracks', - if (album.releaseDate.isNotEmpty) album.releaseDate, + if (album.releaseDate.isNotEmpty) + album.releaseDate, ].join(' · '), - style: ZplayType.bodySmall.toStyle(color: tokens.textSecondary), + style: ZplayType.bodySmall.toStyle( + color: tokens.textSecondary, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -3670,17 +3856,25 @@ class _MusicAlbumDetailModal extends StatelessWidget { PillButton( label: 'Play Album', icon: Icons.play_arrow_rounded, - onPressed: () => onPlayTrack(tracks.first, tracks), + onPressed: () => + onPlayTrack(tracks.first, tracks), ), PillButton( label: 'Download Album', variant: PillVariant.secondary, icon: Icons.download_rounded, onPressed: () { - MusicDownloadService.instance.queueTracks(tracks, collectionName: album.title); - ScaffoldMessenger.of(context).showSnackBar( + MusicDownloadService.instance.queueTracks( + tracks, + collectionName: album.title, + ); + ScaffoldMessenger.of( + context, + ).showSnackBar( SnackBar( - content: Text('Added ${tracks.length} tracks from "${album.title}" to download queue'), + content: Text( + 'Added ${tracks.length} tracks from "${album.title}" to download queue', + ), duration: const Duration(seconds: 2), ), ); @@ -3692,7 +3886,10 @@ class _MusicAlbumDetailModal extends StatelessWidget { ), ), IconButton( - icon: Icon(Icons.close_rounded, color: tokens.textEmphasis), + icon: Icon( + Icons.close_rounded, + color: tokens.textEmphasis, + ), onPressed: onClose, ), ], @@ -3702,7 +3899,8 @@ class _MusicAlbumDetailModal extends StatelessWidget { Expanded( child: ListView.separated( itemCount: tracks.length, - separatorBuilder: (_, __) => const SizedBox(height: ZplaySpacing.s8), + separatorBuilder: (_, __) => + const SizedBox(height: ZplaySpacing.s8), itemBuilder: (context, index) { final track = tracks[index]; return _MusicTrackRow( @@ -3772,7 +3970,8 @@ class _MusicCuratedPlaylistDetailModal extends StatelessWidget { cacheManager: AppImageCache.manager, width: isMobile ? 80 : 120, height: isMobile ? 80 : 120, - fit: BoxFit.cover), + fit: BoxFit.cover, + ), ), const SizedBox(width: ZplaySpacing.s16), Expanded( @@ -3781,15 +3980,20 @@ class _MusicCuratedPlaylistDetailModal extends StatelessWidget { children: [ Text( playlist.title, - style: (isMobile ? ZplayType.title : ZplayType.titleLarge) - .toStyle(color: tokens.textPrimary), + style: + (isMobile + ? ZplayType.title + : ZplayType.titleLarge) + .toStyle(color: tokens.textPrimary), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: ZplaySpacing.s4), Text( 'Curated by ${playlist.creatorName} • ${tracks.length} tracks', - style: ZplayType.label.toStyle(color: tokens.textSecondary), + style: ZplayType.label.toStyle( + color: tokens.textSecondary, + ), ), const SizedBox(height: ZplaySpacing.s12), if (tracks.isNotEmpty) @@ -3800,17 +4004,23 @@ class _MusicCuratedPlaylistDetailModal extends StatelessWidget { PillButton( label: 'Play Playlist', icon: Icons.play_arrow_rounded, - onPressed: () => onPlayTrack(tracks.first, tracks), + onPressed: () => + onPlayTrack(tracks.first, tracks), ), PillButton( label: 'Download Playlist', variant: PillVariant.secondary, icon: Icons.download_rounded, onPressed: () { - MusicDownloadService.instance.queueTracks(tracks, collectionName: playlist.title); + MusicDownloadService.instance.queueTracks( + tracks, + collectionName: playlist.title, + ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Added ${tracks.length} tracks from "${playlist.title}" to download queue'), + content: Text( + 'Added ${tracks.length} tracks from "${playlist.title}" to download queue', + ), duration: const Duration(seconds: 2), ), ); @@ -3822,7 +4032,10 @@ class _MusicCuratedPlaylistDetailModal extends StatelessWidget { ), ), IconButton( - icon: Icon(Icons.close_rounded, color: tokens.textEmphasis), + icon: Icon( + Icons.close_rounded, + color: tokens.textEmphasis, + ), onPressed: onClose, ), ], @@ -3831,7 +4044,8 @@ class _MusicCuratedPlaylistDetailModal extends StatelessWidget { Expanded( child: ListView.separated( itemCount: tracks.length, - separatorBuilder: (_, __) => const SizedBox(height: ZplaySpacing.s8), + separatorBuilder: (_, __) => + const SizedBox(height: ZplaySpacing.s8), itemBuilder: (context, index) { final track = tracks[index]; return _MusicTrackRow( @@ -3913,13 +4127,18 @@ class _MusicUserPlaylistDetailModal extends StatelessWidget { children: [ Text( playlist.title, - style: (isMobile ? ZplayType.titleLarge : ZplayType.display) - .toStyle(color: tokens.textPrimary), + style: + (isMobile + ? ZplayType.titleLarge + : ZplayType.display) + .toStyle(color: tokens.textPrimary), ), const SizedBox(height: ZplaySpacing.s4), Text( 'Custom Playlist • ${tracks.length} tracks', - style: ZplayType.label.toStyle(color: tokens.textSecondary), + style: ZplayType.label.toStyle( + color: tokens.textSecondary, + ), ), const SizedBox(height: ZplaySpacing.s12), if (tracks.isNotEmpty) @@ -3930,17 +4149,23 @@ class _MusicUserPlaylistDetailModal extends StatelessWidget { PillButton( label: 'Play Playlist', icon: Icons.play_arrow_rounded, - onPressed: () => onPlayTrack(tracks.first, tracks), + onPressed: () => + onPlayTrack(tracks.first, tracks), ), PillButton( label: 'Download All', variant: PillVariant.secondary, icon: Icons.download_rounded, onPressed: () { - MusicDownloadService.instance.queueTracks(tracks, collectionName: playlist.title); + MusicDownloadService.instance.queueTracks( + tracks, + collectionName: playlist.title, + ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Added ${tracks.length} tracks from "${playlist.title}" to download queue'), + content: Text( + 'Added ${tracks.length} tracks from "${playlist.title}" to download queue', + ), duration: const Duration(seconds: 2), ), ); @@ -3952,7 +4177,10 @@ class _MusicUserPlaylistDetailModal extends StatelessWidget { ), ), IconButton( - icon: Icon(Icons.close_rounded, color: tokens.textEmphasis), + icon: Icon( + Icons.close_rounded, + color: tokens.textEmphasis, + ), onPressed: onClose, ), ], @@ -3963,12 +4191,15 @@ class _MusicUserPlaylistDetailModal extends StatelessWidget { ? Center( child: Text( 'No tracks in this playlist yet', - style: ZplayType.body.toStyle(color: tokens.textMuted), + style: ZplayType.body.toStyle( + color: tokens.textMuted, + ), ), ) : ListView.separated( itemCount: tracks.length, - separatorBuilder: (_, __) => const SizedBox(height: ZplaySpacing.s8), + separatorBuilder: (_, __) => + const SizedBox(height: ZplaySpacing.s8), itemBuilder: (context, index) { final track = tracks[index]; return FocusableInkWell( @@ -3993,22 +4224,28 @@ class _MusicUserPlaylistDetailModal extends StatelessWidget { memCacheWidth: 132, width: 44, height: 44, - fit: BoxFit.cover), + fit: BoxFit.cover, + ), ), const SizedBox(width: ZplaySpacing.s12), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.start, children: [ Text( track.title, - style: ZplayType.subtitle.toStyle(color: tokens.textPrimary), + style: ZplayType.subtitle.toStyle( + color: tokens.textPrimary, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( track.artist, - style: ZplayType.bodySmall.toStyle(color: tokens.textSecondary), + style: ZplayType.bodySmall.toStyle( + color: tokens.textSecondary, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -4016,7 +4253,10 @@ class _MusicUserPlaylistDetailModal extends StatelessWidget { ), ), IconButton( - icon: Icon(Icons.delete_outline_rounded, color: tokens.textMuted), + icon: Icon( + Icons.delete_outline_rounded, + color: tokens.textMuted, + ), onPressed: () => onRemoveTrack(track.id), ), ], @@ -4056,7 +4296,8 @@ class _MusicExpandedPlayer extends StatefulWidget { State<_MusicExpandedPlayer> createState() => _MusicExpandedPlayerState(); } -class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleTickerProviderStateMixin { +class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> + with SingleTickerProviderStateMixin { late AnimationController _discAnimController; @override @@ -4076,7 +4317,8 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT super.didUpdateWidget(oldWidget); if (widget.playerController.isPlaying && !_discAnimController.isAnimating) { _discAnimController.repeat(); - } else if (!widget.playerController.isPlaying && _discAnimController.isAnimating) { + } else if (!widget.playerController.isPlaying && + _discAnimController.isAnimating) { _discAnimController.stop(); } } @@ -4114,7 +4356,9 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT children: [ IconButton( icon: Icon( - isDesktop ? Icons.close_rounded : Icons.keyboard_arrow_down_rounded, + isDesktop + ? Icons.close_rounded + : Icons.keyboard_arrow_down_rounded, color: tokens.textPrimary, size: isDesktop ? 24 : 32, ), @@ -4124,7 +4368,8 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT _AudioSourceChip( label: widget.playerController.currentQualityLabel.toUpperCase(), lossless: widget.playerController.isCurrentTrackLossless, - onTap: () => _AudioSourceSelectorButton._showAudioSourceDialog(context), + onTap: () => + _AudioSourceSelectorButton._showAudioSourceDialog(context), ), const Spacer(), IconButton( @@ -4133,15 +4378,30 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT ), ], ), - if (!isDesktop) const Spacer() else const SizedBox(height: ZplaySpacing.s12), + if (!isDesktop) + const Spacer() + else + const SizedBox(height: ZplaySpacing.s12), // Preset-based or Custom Arranged Body if (preset == MusicFullscreenPreset.customStudio) - ...order.map((key) => _buildCustomComponent(key, track, palette, seekStyle, artStyle, artSize)) + ...order.map( + (key) => _buildCustomComponent( + key, + track, + palette, + seekStyle, + artStyle, + artSize, + ), + ) else ..._buildPresetBody(preset, track, palette, seekStyle, artSize), - if (!isDesktop) const Spacer() else const SizedBox(height: ZplaySpacing.s12), + if (!isDesktop) + const Spacer() + else + const SizedBox(height: ZplaySpacing.s12), ], ); @@ -4155,9 +4415,7 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT onTap: widget.onCollapse, child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Container( - color: Colors.black.withValues(alpha: 0.65), - ), + child: Container(color: Colors.black.withValues(alpha: 0.65)), ), ), ), @@ -4196,14 +4454,18 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT memCacheWidth: 256, memCacheHeight: 256, fit: BoxFit.cover, - errorWidget: (_, __, ___) => const SizedBox.shrink()), + errorWidget: (_, __, ___) => + const SizedBox.shrink(), + ), ), ), Positioned.fill( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50), child: Container( - color: tokens.surfaceOverlay.withValues(alpha: 0.85), + color: tokens.surfaceOverlay.withValues( + alpha: 0.85, + ), ), ), ), @@ -4237,15 +4499,14 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT imageUrl: track.coverUrl, cacheManager: AppImageCache.manager, fit: BoxFit.cover, - errorWidget: (_, __, ___) => const SizedBox.shrink()), + errorWidget: (_, __, ___) => const SizedBox.shrink(), + ), ), ), Positioned.fill( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50), - child: Container( - color: tokens.bg.withValues(alpha: 0.85), - ), + child: Container(color: tokens.bg.withValues(alpha: 0.85)), ), ), @@ -4297,8 +4558,8 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT style: preset == MusicFullscreenPreset.cyberWaveform ? MusicSeekbarStyle.waveformEqualizer : (preset == MusicFullscreenPreset.liquidGlassNeo - ? MusicSeekbarStyle.liquidGlassSlider - : seekStyle), + ? MusicSeekbarStyle.liquidGlassSlider + : seekStyle), onSeek: (pos) => widget.playerController.seekTo(pos), ), @@ -4372,11 +4633,19 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT mainAxisAlignment: MainAxisAlignment.center, children: [ IconButton( - icon: Icon(Icons.format_quote_rounded, color: tokens.textEmphasis, size: 22), + icon: Icon( + Icons.format_quote_rounded, + color: tokens.textEmphasis, + size: 22, + ), onPressed: widget.onCollapse, ), IconButton( - icon: Icon(Icons.queue_music_rounded, color: tokens.textEmphasis, size: 22), + icon: Icon( + Icons.queue_music_rounded, + color: tokens.textEmphasis, + size: 22, + ), onPressed: widget.onQueueTap, ), ], @@ -4435,7 +4704,9 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT child: Padding( padding: const EdgeInsets.all(ZplaySpacing.s4), child: Icon( - widget.isSaved ? Icons.favorite_rounded : Icons.favorite_border_rounded, + widget.isSaved + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, color: widget.isSaved ? tokens.danger : tokens.textEmphasis, size: 28, ), @@ -4462,7 +4733,9 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT padding: const EdgeInsets.all(ZplaySpacing.s8), child: Icon( Icons.shuffle_rounded, - color: widget.playerController.isShuffle ? tokens.accent : tokens.textMuted, + color: widget.playerController.isShuffle + ? tokens.accent + : tokens.textMuted, size: 24, ), ), @@ -4474,7 +4747,11 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT onTap: widget.playerController.playPrevious, child: Padding( padding: const EdgeInsets.all(ZplaySpacing.s8), - child: Icon(Icons.skip_previous_rounded, color: tokens.textPrimary, size: 36), + child: Icon( + Icons.skip_previous_rounded, + color: tokens.textPrimary, + size: 36, + ), ), ), MusicInteractivePhysicsButton( @@ -4486,7 +4763,10 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT ? SizedBox( width: 44, height: 44, - child: CircularProgressIndicator(color: tokens.accent, strokeWidth: 3), + child: CircularProgressIndicator( + color: tokens.accent, + strokeWidth: 3, + ), ) : _buildExpandedPlayButtonIcon(playBtnStyle, palette), ), @@ -4497,7 +4777,11 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT onTap: widget.playerController.playNext, child: Padding( padding: const EdgeInsets.all(ZplaySpacing.s8), - child: Icon(Icons.skip_next_rounded, color: tokens.textPrimary, size: 36), + child: Icon( + Icons.skip_next_rounded, + color: tokens.textPrimary, + size: 36, + ), ), ), MusicInteractivePhysicsButton( @@ -4511,7 +4795,9 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT widget.playerController.repeatMode == MusicRepeatMode.one ? Icons.repeat_one_rounded : Icons.repeat_rounded, - color: widget.playerController.repeatMode != MusicRepeatMode.off ? tokens.accent : tokens.textMuted, + color: widget.playerController.repeatMode != MusicRepeatMode.off + ? tokens.accent + : tokens.textMuted, size: 24, ), ), @@ -4520,7 +4806,10 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT ); } - Widget _buildExpandedPlayButtonIcon(MusicPlayButtonStyle style, AppThemePalette palette) { + Widget _buildExpandedPlayButtonIcon( + MusicPlayButtonStyle style, + AppThemePalette palette, + ) { final isPlaying = widget.playerController.isPlaying; final icon = isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded; final tokens = context.tokens; @@ -4550,9 +4839,14 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT height: 64, decoration: BoxDecoration( borderRadius: ZplayRadius.mdAll, - gradient: LinearGradient(colors: [tokens.accent, palette.accentColor]), + gradient: LinearGradient( + colors: [tokens.accent, palette.accentColor], + ), boxShadow: [ - BoxShadow(color: tokens.accent.withValues(alpha: 0.6), blurRadius: 20), + BoxShadow( + color: tokens.accent.withValues(alpha: 0.6), + blurRadius: 20, + ), ], ), child: Icon(icon, color: tokens.onAccent, size: 38), @@ -4567,7 +4861,11 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT shape: BoxShape.circle, gradient: LinearGradient(colors: [tokens.accent, palette.accentColor]), boxShadow: [ - BoxShadow(color: tokens.accent.withValues(alpha: 0.6), blurRadius: 22, spreadRadius: 2), + BoxShadow( + color: tokens.accent.withValues(alpha: 0.6), + blurRadius: 22, + spreadRadius: 2, + ), ], ), child: Icon(icon, color: tokens.onAccent, size: 40), @@ -4580,7 +4878,9 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT return AnimatedBuilder( animation: _discAnimController, builder: (context, child) => Transform.rotate( - angle: widget.playerController.isPlaying ? _discAnimController.value * 2 * math.pi : 0, + angle: widget.playerController.isPlaying + ? _discAnimController.value * 2 * math.pi + : 0, child: child, ), child: Container( @@ -4605,7 +4905,8 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT cacheManager: AppImageCache.manager, width: size * 0.44, height: size * 0.44, - fit: BoxFit.cover), + fit: BoxFit.cover, + ), ), ), ), @@ -4632,7 +4933,8 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT child: CachedNetworkImage( imageUrl: track.coverUrl, cacheManager: AppImageCache.manager, - fit: BoxFit.cover), + fit: BoxFit.cover, + ), ), ); } @@ -4662,7 +4964,8 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT child: CachedNetworkImage( imageUrl: track.coverUrl, cacheManager: AppImageCache.manager, - fit: BoxFit.cover), + fit: BoxFit.cover, + ), ), ), ); @@ -4676,11 +4979,16 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT cacheManager: AppImageCache.manager, width: size, height: size, - fit: BoxFit.cover), + fit: BoxFit.cover, + ), ); } - Widget _buildCustomArtworkByStyle(MusicTrack track, double size, MusicArtworkStyle style) { + Widget _buildCustomArtworkByStyle( + MusicTrack track, + double size, + MusicArtworkStyle style, + ) { final tokens = context.tokens; if (style == MusicArtworkStyle.vinylSpinningDisc) { @@ -4707,7 +5015,8 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT child: CachedNetworkImage( imageUrl: track.coverUrl, cacheManager: AppImageCache.manager, - fit: BoxFit.cover), + fit: BoxFit.cover, + ), ), ); } @@ -4752,7 +5061,11 @@ class _MusicShortcutsModal extends StatelessWidget { children: [ Row( children: [ - Icon(Icons.keyboard_rounded, color: tokens.accent, size: 24), + Icon( + Icons.keyboard_rounded, + color: tokens.accent, + size: 24, + ), const SizedBox(width: ZplaySpacing.s8), Text( 'Keyboard Shortcuts', @@ -4760,7 +5073,10 @@ class _MusicShortcutsModal extends StatelessWidget { ), const Spacer(), IconButton( - icon: Icon(Icons.close_rounded, color: tokens.textEmphasis), + icon: Icon( + Icons.close_rounded, + color: tokens.textEmphasis, + ), onPressed: onClose, ), ], @@ -4768,7 +5084,9 @@ class _MusicShortcutsModal extends StatelessWidget { const SizedBox(height: ZplaySpacing.s16), for (final s in shortcuts) Padding( - padding: const EdgeInsets.symmetric(vertical: ZplaySpacing.s4), + padding: const EdgeInsets.symmetric( + vertical: ZplaySpacing.s4, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -4784,12 +5102,16 @@ class _MusicShortcutsModal extends StatelessWidget { ), child: Text( s['key']!, - style: ZplayType.bodySmall.toStyle(color: tokens.accent), + style: ZplayType.bodySmall.toStyle( + color: tokens.accent, + ), ), ), Text( s['desc']!, - style: ZplayType.label.toStyle(color: tokens.textEmphasis), + style: ZplayType.label.toStyle( + color: tokens.textEmphasis, + ), ), ], ), @@ -4815,10 +5137,12 @@ class _MusicDownloadedTracksModal extends StatefulWidget { }); @override - State<_MusicDownloadedTracksModal> createState() => _MusicDownloadedTracksModalState(); + State<_MusicDownloadedTracksModal> createState() => + _MusicDownloadedTracksModalState(); } -class _MusicDownloadedTracksModalState extends State<_MusicDownloadedTracksModal> { +class _MusicDownloadedTracksModalState + extends State<_MusicDownloadedTracksModal> { final TextEditingController _searchController = TextEditingController(); String _searchQuery = ''; @@ -4838,10 +5162,18 @@ class _MusicDownloadedTracksModalState extends State<_MusicDownloadedTracksModal final filtered = _searchQuery.isEmpty ? allDownloaded - : allDownloaded.where((t) => - t.title.toLowerCase().contains(_searchQuery.toLowerCase()) || - t.artist.toLowerCase().contains(_searchQuery.toLowerCase()) || - t.album.toLowerCase().contains(_searchQuery.toLowerCase())).toList(); + : allDownloaded + .where( + (t) => + t.title.toLowerCase().contains( + _searchQuery.toLowerCase(), + ) || + t.artist.toLowerCase().contains( + _searchQuery.toLowerCase(), + ) || + t.album.toLowerCase().contains(_searchQuery.toLowerCase()), + ) + .toList(); final size = MediaQuery.sizeOf(context); final isMobile = size.width < 700; @@ -4891,19 +5223,27 @@ class _MusicDownloadedTracksModalState extends State<_MusicDownloadedTracksModal children: [ Text( 'Downloaded Songs', - style: (isMobile ? ZplayType.title : ZplayType.titleLarge) - .toStyle(color: tokens.textPrimary), + style: + (isMobile + ? ZplayType.title + : ZplayType.titleLarge) + .toStyle(color: tokens.textPrimary), ), const SizedBox(height: ZplaySpacing.s2), Text( '${allDownloaded.length} offline tracks • $sizeMb MB storage', - style: ZplayType.bodySmall.toStyle(color: tokens.textSecondary), + style: ZplayType.bodySmall.toStyle( + color: tokens.textSecondary, + ), ), ], ), ), IconButton( - icon: Icon(Icons.close_rounded, color: tokens.textEmphasis), + icon: Icon( + Icons.close_rounded, + color: tokens.textEmphasis, + ), onPressed: widget.onClose, ), ], @@ -4925,7 +5265,9 @@ class _MusicDownloadedTracksModalState extends State<_MusicDownloadedTracksModal label: 'Play All', icon: Icons.play_arrow_rounded, onPressed: () { - final tracks = filtered.map((d) => d.toMusicTrack()).toList(); + final tracks = filtered + .map((d) => d.toMusicTrack()) + .toList(); widget.onPlayTrack(tracks.first, tracks); }, ), @@ -4935,7 +5277,9 @@ class _MusicDownloadedTracksModalState extends State<_MusicDownloadedTracksModal variant: PillVariant.secondary, icon: Icons.shuffle_rounded, onPressed: () { - final tracks = filtered.map((d) => d.toMusicTrack()).toList()..shuffle(); + final tracks = + filtered.map((d) => d.toMusicTrack()).toList() + ..shuffle(); widget.onPlayTrack(tracks.first, tracks); }, ), @@ -4947,11 +5291,19 @@ class _MusicDownloadedTracksModalState extends State<_MusicDownloadedTracksModal child: TextField( controller: _searchController, onChanged: (v) => setState(() => _searchQuery = v), - style: ZplayType.bodySmall.toStyle(color: tokens.textPrimary), + style: ZplayType.bodySmall.toStyle( + color: tokens.textPrimary, + ), decoration: InputDecoration( hintText: 'Search offline...', - hintStyle: ZplayType.bodySmall.toStyle(color: tokens.textMuted), - prefixIcon: Icon(Icons.search_rounded, color: tokens.info, size: 16), + hintStyle: ZplayType.bodySmall.toStyle( + color: tokens.textMuted, + ), + prefixIcon: Icon( + Icons.search_rounded, + color: tokens.info, + size: 16, + ), filled: true, fillColor: tokens.borderSubtle, contentPadding: const EdgeInsets.symmetric( @@ -4984,7 +5336,9 @@ class _MusicDownloadedTracksModalState extends State<_MusicDownloadedTracksModal mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - _searchQuery.isNotEmpty ? Icons.search_off_rounded : Icons.cloud_download_rounded, + _searchQuery.isNotEmpty + ? Icons.search_off_rounded + : Icons.cloud_download_rounded, color: tokens.textDisabled, size: 48, ), @@ -5003,11 +5357,14 @@ class _MusicDownloadedTracksModalState extends State<_MusicDownloadedTracksModal ) : ListView.separated( itemCount: filtered.length, - separatorBuilder: (_, __) => const SizedBox(height: ZplaySpacing.s4), + separatorBuilder: (_, __) => + const SizedBox(height: ZplaySpacing.s4), itemBuilder: (context, index) { final item = filtered[index]; final track = item.toMusicTrack(); - final itemSizeMb = (item.fileSizeBytes / (1024 * 1024)).toStringAsFixed(1); + final itemSizeMb = + (item.fileSizeBytes / (1024 * 1024)) + .toStringAsFixed(1); final isFlac = item.format.toLowerCase() == 'flac'; return ListTile( @@ -5021,19 +5378,24 @@ class _MusicDownloadedTracksModalState extends State<_MusicDownloadedTracksModal tileColor: tokens.surface, leading: ClipRRect( borderRadius: ZplayRadius.smAll, - child: item.localCoverPath.isNotEmpty && File(item.localCoverPath).existsSync() + child: + item.localCoverPath.isNotEmpty && + File(item.localCoverPath).existsSync() ? Image.file( File(item.localCoverPath), width: 46, height: 46, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildFallbackCover(track), + errorBuilder: (_, __, ___) => + _buildFallbackCover(track), ) : _buildFallbackCover(track), ), title: Text( item.title, - style: ZplayType.label.toStyle(color: tokens.textPrimary), + style: ZplayType.label.toStyle( + color: tokens.textPrimary, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -5042,7 +5404,9 @@ class _MusicDownloadedTracksModalState extends State<_MusicDownloadedTracksModal Expanded( child: Text( '${item.artist} • ${item.album}', - style: ZplayType.caption.toStyle(color: tokens.textSecondary), + style: ZplayType.caption.toStyle( + color: tokens.textSecondary, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -5054,21 +5418,28 @@ class _MusicDownloadedTracksModalState extends State<_MusicDownloadedTracksModal vertical: ZplaySpacing.s2, ), decoration: BoxDecoration( - color: (isFlac ? tokens.accent : tokens.info) - .withValues(alpha: 0.25), + color: + (isFlac ? tokens.accent : tokens.info) + .withValues(alpha: 0.25), borderRadius: ZplayRadius.xsAll, ), child: Text( - isFlac ? 'FLAC' : item.format.toUpperCase(), + isFlac + ? 'FLAC' + : item.format.toUpperCase(), style: ZplayType.overline.toStyle( - color: isFlac ? tokens.accentHover : tokens.info, + color: isFlac + ? tokens.accentHover + : tokens.info, ), ), ), const SizedBox(width: ZplaySpacing.s4), Text( '$itemSizeMb MB', - style: ZplayType.caption.toStyle(color: tokens.textMuted), + style: ZplayType.caption.toStyle( + color: tokens.textMuted, + ), ), ], ), @@ -5076,36 +5447,48 @@ class _MusicDownloadedTracksModalState extends State<_MusicDownloadedTracksModal mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: Icon(Icons.delete_outline_rounded, color: tokens.textMuted, size: 20), + icon: Icon( + Icons.delete_outline_rounded, + color: tokens.textMuted, + size: 20, + ), tooltip: 'Delete from downloads', onPressed: () async { final confirm = await showDialog( context: context, builder: (c) => AlertDialog( - backgroundColor: tokens.surfaceOverlay, + backgroundColor: + tokens.surfaceOverlay, title: Text( 'Delete Downloaded Song', - style: ZplayType.title.toStyle(color: tokens.textPrimary), + style: ZplayType.title.toStyle( + color: tokens.textPrimary, + ), ), content: Text( 'Delete "${item.title}" from offline storage?', - style: ZplayType.body.toStyle(color: tokens.textEmphasis), + style: ZplayType.body.toStyle( + color: tokens.textEmphasis, + ), ), actions: [ PillButton( label: 'Cancel', variant: PillVariant.secondary, - onPressed: () => Navigator.pop(c, false), + onPressed: () => + Navigator.pop(c, false), ), PillButton( label: 'Delete', - onPressed: () => Navigator.pop(c, true), + onPressed: () => + Navigator.pop(c, true), ), ], ), ); if (confirm == true) { - await downloadService.deleteDownloadedTrack(item.id); + await downloadService + .deleteDownloadedTrack(item.id); setState(() {}); } }, @@ -5113,7 +5496,9 @@ class _MusicDownloadedTracksModalState extends State<_MusicDownloadedTracksModal ], ), onTap: () { - final allTracks = filtered.map((d) => d.toMusicTrack()).toList(); + final allTracks = filtered + .map((d) => d.toMusicTrack()) + .toList(); widget.onPlayTrack(track, allTracks); }, ); @@ -5143,8 +5528,13 @@ class _MusicDownloadedTracksModalState extends State<_MusicDownloadedTracksModal width: 46, height: 46, color: tokens.surface, - child: Icon(Icons.music_note_rounded, color: tokens.textMuted, size: 24), - )); + child: Icon( + Icons.music_note_rounded, + color: tokens.textMuted, + size: 24, + ), + ), + ); } return Container( width: 46, @@ -5154,10 +5544,15 @@ class _MusicDownloadedTracksModalState extends State<_MusicDownloadedTracksModal ); } - Widget _buildQueueBanner(List queue, MusicDownloadService service) { + Widget _buildQueueBanner( + List queue, + MusicDownloadService service, + ) { final tokens = context.tokens; final activeTask = queue.firstWhere( - (t) => t.status == MusicDownloadStatus.downloading || t.status == MusicDownloadStatus.extracting, + (t) => + t.status == MusicDownloadStatus.downloading || + t.status == MusicDownloadStatus.extracting, orElse: () => queue.first, ); final isExtracting = activeTask.status == MusicDownloadStatus.extracting; @@ -5171,7 +5566,9 @@ class _MusicDownloadedTracksModalState extends State<_MusicDownloadedTracksModal decoration: BoxDecoration( color: tokens.info.withValues(alpha: ZplayOpacity.overlayHover), borderRadius: ZplayRadius.mdAll, - border: Border.all(color: tokens.info.withValues(alpha: ZplayOpacity.textMuted)), + border: Border.all( + color: tokens.info.withValues(alpha: ZplayOpacity.textMuted), + ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -5208,7 +5605,11 @@ class _MusicDownloadedTracksModalState extends State<_MusicDownloadedTracksModal onTap: () => service.cancelTask(activeTask.track.id), child: Padding( padding: const EdgeInsets.all(ZplaySpacing.s4), - child: Icon(Icons.close_rounded, color: tokens.textSecondary, size: 16), + child: Icon( + Icons.close_rounded, + color: tokens.textSecondary, + size: 16, + ), ), ), ], diff --git a/lib/services/anime/extractors/tryembed_extractor.dart b/lib/services/anime/extractors/tryembed_extractor.dart index 114f7a12..8aded398 100644 --- a/lib/services/anime/extractors/tryembed_extractor.dart +++ b/lib/services/anime/extractors/tryembed_extractor.dart @@ -65,7 +65,8 @@ class TryEmbedExtractor { final results = []; try { final cat = category.toLowerCase() == 'dub' ? 'dub' : 'sub'; - final embedUrl = '$baseUrl/embed/anime/$anilistId/$episodeNumber/$cat?autoSkip=true'; + final embedUrl = + '$baseUrl/embed/anime/$anilistId/$episodeNumber/$cat?autoSkip=true'; final embedReq = await _httpClient.getUrl(Uri.parse(embedUrl)); embedReq.headers.set('User-Agent', _userAgent); @@ -76,7 +77,9 @@ class TryEmbedExtractor { embedReq.headers.set('Accept-Language', 'en-US,en;q=0.9'); embedReq.headers.set('Referer', 'https://google.com/'); - final embedRes = await embedReq.close().timeout(const Duration(seconds: 10)); + final embedRes = await embedReq.close().timeout( + const Duration(seconds: 10), + ); if (embedRes.statusCode != 200) { if (kDebugMode) { debugPrint('[TryEmbed] Embed request failed: ${embedRes.statusCode}'); @@ -90,8 +93,9 @@ class TryEmbedExtractor { } final body = await embedRes.transform(utf8.decoder).join(); - final nonceMatch = - RegExp(r'''EMBED_NONCE\s*=\s*["']([^"']+)["']''').firstMatch(body); + final nonceMatch = RegExp( + r'''EMBED_NONCE\s*=\s*["']([^"']+)["']''', + ).firstMatch(body); final nonce = nonceMatch?.group(1) ?? ''; String buildCookieHeader() => @@ -116,7 +120,9 @@ class TryEmbedExtractor { final apiRes = await apiReq.close().timeout(const Duration(seconds: 10)); if (apiRes.statusCode != 200) { if (kDebugMode) { - debugPrint('[TryEmbed] Stream data request failed: ${apiRes.statusCode}'); + debugPrint( + '[TryEmbed] Stream data request failed: ${apiRes.statusCode}', + ); } return results; } @@ -134,7 +140,8 @@ class TryEmbedExtractor { if (data['captions'] is List) { for (final item in data['captions']) { if (item is Map) { - final trackUrl = item['url']?.toString() ?? item['file']?.toString() ?? ''; + final trackUrl = + item['url']?.toString() ?? item['file']?.toString() ?? ''; if (trackUrl.isNotEmpty) { globalTracks.add(TryEmbedTrack.fromJson(item)); } @@ -165,7 +172,10 @@ class TryEmbedExtractor { for (final provider in providers) { if (provider is! Map) continue; - final serverName = provider['name']?.toString() ?? provider['id']?.toString() ?? 'Server'; + final serverName = + provider['name']?.toString() ?? + provider['id']?.toString() ?? + 'Server'; final qualities = provider['qualities'] as List? ?? []; if (qualities.isEmpty) continue; @@ -193,8 +203,10 @@ class TryEmbedExtractor { if (provider['captions'] is List) { for (final item in provider['captions']) { if (item is Map) { - final trackUrl = item['url']?.toString() ?? item['file']?.toString() ?? ''; - if (trackUrl.isNotEmpty && !tracks.any((t) => t.file == trackUrl)) { + final trackUrl = + item['url']?.toString() ?? item['file']?.toString() ?? ''; + if (trackUrl.isNotEmpty && + !tracks.any((t) => t.file == trackUrl)) { tracks.add(TryEmbedTrack.fromJson(item)); } } diff --git a/lib/services/anime_arabic/mega_proxy.dart b/lib/services/anime_arabic/mega_proxy.dart index f140c57e..008145f7 100644 --- a/lib/services/anime_arabic/mega_proxy.dart +++ b/lib/services/anime_arabic/mega_proxy.dart @@ -54,9 +54,12 @@ class MegaProxy { Future _ensureServer() async { if (_server != null) return _server!.port; final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - s.listen(_handle, onError: (e, st) { - debugPrint('[MegaProxy] listen error: $e'); - }); + s.listen( + _handle, + onError: (e, st) { + debugPrint('[MegaProxy] listen error: $e'); + }, + ); _server = s; debugPrint('[MegaProxy] bound on 127.0.0.1:${s.port}'); return s.port; @@ -125,9 +128,11 @@ class MegaProxy { Uri.parse('https://g.api.mega.co.nz/cs?id=${_seq++}'), ); req.headers.set('Content-Type', 'application/json'); - req.write(jsonEncode([ - {'a': 'g', 'g': 1, 'ssl': 1, 'n': fileId} - ])); + req.write( + jsonEncode([ + {'a': 'g', 'g': 1, 'ssl': 1, 'n': fileId}, + ]), + ); final res = await req.close(); final body = await res.transform(utf8.decoder).join(); final list = jsonDecode(body); @@ -168,7 +173,10 @@ class MegaProxy { if (origin != null) { req.response.headers.set('Access-Control-Allow-Origin', origin); req.response.headers.set('Vary', 'Origin'); - req.response.headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS'); + req.response.headers.set( + 'Access-Control-Allow-Methods', + 'GET, HEAD, OPTIONS', + ); req.response.headers.set('Access-Control-Allow-Headers', 'Range'); } if (req.method == 'OPTIONS') { @@ -189,16 +197,23 @@ class MegaProxy { if (rangeHeader != null && rangeHeader.startsWith('bytes=')) { final spec = rangeHeader.substring(6).split('-').first; final requestedStart = int.tryParse(spec); - if (requestedStart != null && requestedStart >= 0 && requestedStart < file.size) { + if (requestedStart != null && + requestedStart >= 0 && + requestedStart < file.size) { start = requestedStart; isPartial = true; } } final contentLength = end - start + 1; - req.response.statusCode = isPartial ? HttpStatus.partialContent : HttpStatus.ok; + req.response.statusCode = isPartial + ? HttpStatus.partialContent + : HttpStatus.ok; if (isPartial) { - req.response.headers.set('Content-Range', 'bytes $start-$end/${file.size}'); + req.response.headers.set( + 'Content-Range', + 'bytes $start-$end/${file.size}', + ); } req.response.headers.set('Content-Length', contentLength.toString()); @@ -219,13 +234,7 @@ class MegaProxy { final cipher = _initCtrCipher(file.aesKey, file.nonce, start); await for (final chunk in upRes) { final out = Uint8List(chunk.length); - cipher.processBytes( - Uint8List.fromList(chunk), - 0, - chunk.length, - out, - 0, - ); + cipher.processBytes(Uint8List.fromList(chunk), 0, chunk.length, out, 0); req.response.add(out); } await req.response.close(); @@ -252,10 +261,7 @@ class MegaProxy { bd.setUint64(8, blockOffset, Endian.big); final cipher = CTRStreamCipher(AESEngine()) - ..init( - false, - ParametersWithIV(KeyParameter(aesKey), iv), - ); + ..init(false, ParametersWithIV(KeyParameter(aesKey), iv)); // If byteOffset isn't 16-byte aligned, burn initial intra-block keystream bytes final intraBlockSkip = byteOffset % 16; @@ -271,8 +277,9 @@ class MegaProxy { // ──────────────────────────────────────────────────────────────────── (String, Uint8List)? _parseEmbed(String embedUrl) { // mega.nz/embed/! or mega.nz/embed/!! - final m = RegExp(r'mega\.(?:nz|co\.nz)/embed/(?:#|!)?([A-Za-z0-9_-]+)!([A-Za-z0-9_-]+)') - .firstMatch(embedUrl); + final m = RegExp( + r'mega\.(?:nz|co\.nz)/embed/(?:#|!)?([A-Za-z0-9_-]+)!([A-Za-z0-9_-]+)', + ).firstMatch(embedUrl); if (m == null) return null; final fileId = m.group(1)!; final keyB64 = m.group(2)!; diff --git a/lib/services/books/book_download_service.dart b/lib/services/books/book_download_service.dart index c1e268b8..2a8a3703 100644 --- a/lib/services/books/book_download_service.dart +++ b/lib/services/books/book_download_service.dart @@ -32,7 +32,9 @@ class BookDownloadService { _booksDirectory = dir; return dir; } catch (_) { - final temp = Directory(p.join(Directory.systemTemp.path, 'ZPlay', 'Books')); + final temp = Directory( + p.join(Directory.systemTemp.path, 'ZPlay', 'Books'), + ); if (!await temp.exists()) { await temp.create(recursive: true); } @@ -112,13 +114,17 @@ class BookDownloadService { try { final uri = Uri.parse(urlStr); final request = await client.getUrl(uri); - request.headers.set('User-Agent', - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'); + request.headers.set( + 'User-Agent', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36', + ); request.headers.set('Accept', '*/*'); request.headers.set('Referer', 'https://bookracy.com/'); request.headers.set('Origin', 'https://bookracy.com'); - request.headers.set('sec-ch-ua', - '"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"'); + request.headers.set( + 'sec-ch-ua', + '"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"', + ); final response = await request.close(); @@ -188,7 +194,8 @@ class BookDownloadService { } _removeProgress(md5); - throw lastException ?? Exception('Failed to download book after trying all sources'); + throw lastException ?? + Exception('Failed to download book after trying all sources'); } /// Deletes a downloaded book from local storage. diff --git a/lib/services/books/bookracy_service.dart b/lib/services/books/bookracy_service.dart index 219aa82b..69d474b5 100644 --- a/lib/services/books/bookracy_service.dart +++ b/lib/services/books/bookracy_service.dart @@ -42,9 +42,9 @@ class BookracyService { ); try { - final response = await _client.get(uri, headers: _headers).timeout( - const Duration(seconds: 15), - ); + final response = await _client + .get(uri, headers: _headers) + .timeout(const Duration(seconds: 15)); if (response.statusCode != 200) { if (kDebugMode) { @@ -72,7 +72,12 @@ class BookracyService { final ft = book.bookFiletype.toLowerCase(); // Exclude lit, lrf, and legacy unsupported formats - if (ft == 'lit' || ft == 'lrf' || ft == 'djvu' || ft == 'rtf' || ft == 'doc' || ft == 'docx') { + if (ft == 'lit' || + ft == 'lrf' || + ft == 'djvu' || + ft == 'rtf' || + ft == 'doc' || + ft == 'docx') { continue; } @@ -95,7 +100,9 @@ class BookracyService { return books; } catch (e) { if (kDebugMode) { - debugPrint('[BookracyService] Error searching books ($effectiveQuery): $e'); + debugPrint( + '[BookracyService] Error searching books ($effectiveQuery): $e', + ); } return []; } diff --git a/lib/services/cloudstream/cloudstream_manager.dart b/lib/services/cloudstream/cloudstream_manager.dart index 2859edb4..ca6c863b 100644 --- a/lib/services/cloudstream/cloudstream_manager.dart +++ b/lib/services/cloudstream/cloudstream_manager.dart @@ -53,9 +53,13 @@ class CloudStreamManager { } return List.unmodifiable(res); } - List get activeExtensions => _installed.where((e) => e.enabled).toList(); - List get availableExtensions => List.unmodifiable(_available); - List> get loadedProviders => List.unmodifiable(_loadedProviders); + + List get activeExtensions => + _installed.where((e) => e.enabled).toList(); + List get availableExtensions => + List.unmodifiable(_available); + List> get loadedProviders => + List.unmodifiable(_loadedProviders); static bool get isSupported => !Platform.isIOS; @@ -81,7 +85,9 @@ class CloudStreamManager { Future initialize() async { if (_initialized) return; if (!isSupported) { - debugPrint('[CloudStreamManager] CloudStream extensions are not supported on this platform (iOS).'); + debugPrint( + '[CloudStreamManager] CloudStream extensions are not supported on this platform (iOS).', + ); return; } @@ -117,15 +123,20 @@ class CloudStreamManager { final ext = p.extension(f.path).toLowerCase(); if (ext == '.jar' || ext == '.cs3') { final base = p.basenameWithoutExtension(f.path); - if (!_installed.any((e) => - e.name.toLowerCase() == base.toLowerCase() || - (e.internalName != null && e.internalName!.toLowerCase() == base.toLowerCase()))) { - _installed.add(CloudStreamSource( - id: base, - name: base, - internalName: base, - enabled: true, - )); + if (!_installed.any( + (e) => + e.name.toLowerCase() == base.toLowerCase() || + (e.internalName != null && + e.internalName!.toLowerCase() == base.toLowerCase()), + )) { + _installed.add( + CloudStreamSource( + id: base, + name: base, + internalName: base, + enabled: true, + ), + ); addedAny = true; } } @@ -145,7 +156,9 @@ class CloudStreamManager { try { await _connectRuntime(); } catch (e) { - debugPrint('[CloudStreamManager] Error connecting runtime on startup: $e'); + debugPrint( + '[CloudStreamManager] Error connecting runtime on startup: $e', + ); } } @@ -153,14 +166,20 @@ class CloudStreamManager { try { await seedDefaultRepoIfEligible(); } catch (e) { - debugPrint('[CloudStreamManager] Error checking default repo seed on startup: $e'); + debugPrint( + '[CloudStreamManager] Error checking default repo seed on startup: $e', + ); } _initialized = true; - unawaited(fetchAvailableExtensions().catchError((e) { - debugPrint('[CloudStreamManager] Error reconciling extensions on startup: $e'); - return []; - })); + unawaited( + fetchAvailableExtensions().catchError((e) { + debugPrint( + '[CloudStreamManager] Error reconciling extensions on startup: $e', + ); + return []; + }), + ); } /// Automatically seeds the default Phisher repository built-in if: @@ -173,18 +192,28 @@ class CloudStreamManager { final alreadySeeded = prefs.getBool(_phisherRepoSeededKey) ?? false; if (alreadySeeded) return; - final isReady = Platform.isAndroid || await CloudStreamDownloader.instance.checkIsReady(); + final isReady = + Platform.isAndroid || + await CloudStreamDownloader.instance.checkIsReady(); if (!isReady) return; - final normalizedPhisher = normalizeRepoUrl(defaultPhisherRepoUrl).toLowerCase(); - final alreadyInList = _repos.any((r) => normalizeRepoUrl(r.url).toLowerCase() == normalizedPhisher); + final normalizedPhisher = normalizeRepoUrl( + defaultPhisherRepoUrl, + ).toLowerCase(); + final alreadyInList = _repos.any( + (r) => normalizeRepoUrl(r.url).toLowerCase() == normalizedPhisher, + ); if (!alreadyInList) { try { await addRepo(defaultPhisherRepoUrl); - debugPrint('[CloudStreamManager] Successfully seeded default Phisher repository.'); + debugPrint( + '[CloudStreamManager] Successfully seeded default Phisher repository.', + ); } catch (e) { - debugPrint('[CloudStreamManager] Note on seeding default Phisher repo: $e'); + debugPrint( + '[CloudStreamManager] Note on seeding default Phisher repo: $e', + ); } } @@ -199,9 +228,14 @@ class CloudStreamManager { final key = (s.internalName != null && s.internalName!.isNotEmpty) ? s.internalName!.toLowerCase().trim() : s.name.toLowerCase().trim(); - final stripped = s.name.toLowerCase().replaceAll(RegExp(r'provider|plugin|scraper'), '').trim(); - - final isDup = seen.contains(key) || (stripped.isNotEmpty && seen.contains(stripped)); + final stripped = s.name + .toLowerCase() + .replaceAll(RegExp(r'provider|plugin|scraper'), '') + .trim(); + + final isDup = + seen.contains(key) || + (stripped.isNotEmpty && seen.contains(stripped)); if (!isDup) { seen.add(key); if (stripped.isNotEmpty) seen.add(stripped); @@ -211,16 +245,23 @@ class CloudStreamManager { final uKey = (u.internalName != null && u.internalName!.isNotEmpty) ? u.internalName!.toLowerCase().trim() : u.name.toLowerCase().trim(); - final uStripped = u.name.toLowerCase().replaceAll(RegExp(r'provider|plugin|scraper'), '').trim(); + final uStripped = u.name + .toLowerCase() + .replaceAll(RegExp(r'provider|plugin|scraper'), '') + .trim(); return uKey == key || (stripped.isNotEmpty && uStripped == stripped); }); - if (idx != -1 && (s.pluginUrl != null && s.pluginUrl!.isNotEmpty) && (unique[idx].pluginUrl == null || unique[idx].pluginUrl!.isEmpty)) { + if (idx != -1 && + (s.pluginUrl != null && s.pluginUrl!.isNotEmpty) && + (unique[idx].pluginUrl == null || unique[idx].pluginUrl!.isEmpty)) { unique[idx] = s; } } } if (unique.length != _installed.length) { - debugPrint('[CloudStreamManager] Deduplicated installed plugins: ${_installed.length} -> ${unique.length}'); + debugPrint( + '[CloudStreamManager] Deduplicated installed plugins: ${_installed.length} -> ${unique.length}', + ); _installed.clear(); _installed.addAll(unique); await _saveInstalled(); @@ -230,7 +271,9 @@ class CloudStreamManager { Future _connectRuntime() async { final extDir = await CloudStreamPaths.instance.extensionsDir; await CloudStreamDispatcher.instance.initialize(); - final res = await CloudStreamDispatcher.instance.loadExtensions(extDir.path); + final res = await CloudStreamDispatcher.instance.loadExtensions( + extDir.path, + ); _loadedProviders.clear(); if (res is List) { for (final item in res) { @@ -238,7 +281,9 @@ class CloudStreamManager { _loadedProviders.add(Map.from(item)); } } - debugPrint('[CloudStreamManager] Connected runtime: ${_loadedProviders.length} active providers registered.'); + debugPrint( + '[CloudStreamManager] Connected runtime: ${_loadedProviders.length} active providers registered.', + ); // Reconcile installed extensions with loaded providers bool modified = false; @@ -247,7 +292,9 @@ class CloudStreamManager { if (matched != null && matched['id'] != null) { final realId = matched['id'].toString(); if (ext.internalName != realId) { - debugPrint('[CloudStreamManager] Reconciled installed source ${ext.name} (${ext.internalName}) -> $realId'); + debugPrint( + '[CloudStreamManager] Reconciled installed source ${ext.name} (${ext.internalName}) -> $realId', + ); ext.internalName = realId; modified = true; } @@ -266,9 +313,17 @@ class CloudStreamManager { if (_loadedProviders.isEmpty) return []; final extNameClean = ext.name.toLowerCase().trim(); final extInternalClean = (ext.internalName ?? '').toLowerCase().trim(); - final strippedExtName = extNameClean.replaceAll(RegExp(r'provider|plugin|scraper'), '').trim(); - final sanitizedName = ext.name.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); - final sanitizedStripped = strippedExtName.replaceAll(RegExp(r'[^a-z0-9]'), ''); + final strippedExtName = extNameClean + .replaceAll(RegExp(r'provider|plugin|scraper'), '') + .trim(); + final sanitizedName = ext.name.toLowerCase().replaceAll( + RegExp(r'[^a-z0-9]'), + '', + ); + final sanitizedStripped = strippedExtName.replaceAll( + RegExp(r'[^a-z0-9]'), + '', + ); final results = >[]; @@ -280,19 +335,26 @@ class CloudStreamManager { bool matches = false; // 1. Exact ID or Name match - if (pId == extInternalClean || pId == extNameClean || pName == extNameClean) { + if (pId == extInternalClean || + pId == extNameClean || + pName == extNameClean) { matches = true; } else if (pId == 'cs_$sanitizedName' || pId == 'cs_$sanitizedStripped') { matches = true; - } else if (strippedExtName.isNotEmpty && (pName == strippedExtName || pId == 'cs_$strippedExtName')) { + } else if (strippedExtName.isNotEmpty && + (pName == strippedExtName || pId == 'cs_$strippedExtName')) { matches = true; - } else if (extNameClean.startsWith(pName) || (pName.length >= 4 && extNameClean.contains(pName))) { + } else if (extNameClean.startsWith(pName) || + (pName.length >= 4 && extNameClean.contains(pName))) { matches = true; - } else if (strippedExtName.isNotEmpty && (pName.contains(strippedExtName) || strippedExtName.contains(pName))) { + } else if (strippedExtName.isNotEmpty && + (pName.contains(strippedExtName) || + strippedExtName.contains(pName))) { matches = true; } else if (pClass.endsWith('.$extNameClean') || pClass.endsWith('.$sanitizedName') || - (sanitizedStripped.isNotEmpty && pClass.contains(sanitizedStripped))) { + (sanitizedStripped.isNotEmpty && + pClass.contains(sanitizedStripped))) { matches = true; } @@ -327,7 +389,8 @@ class CloudStreamManager { } } - if (url.toLowerCase().startsWith('http://') || url.toLowerCase().startsWith('https://')) { + if (url.toLowerCase().startsWith('http://') || + url.toLowerCase().startsWith('https://')) { return url; } @@ -348,7 +411,9 @@ class CloudStreamManager { try { final response = await http.get(Uri.parse(url)); if (response.statusCode != 200) { - throw Exception('Failed to fetch repository (HTTP ${response.statusCode})'); + throw Exception( + 'Failed to fetch repository (HTTP ${response.statusCode})', + ); } final body = response.body.trim(); @@ -367,7 +432,16 @@ class CloudStreamManager { final uri = Uri.tryParse(url); if (uri != null && uri.pathSegments.isNotEmpty) { final meaningful = uri.pathSegments - .where((s) => s != 'raw' && s != 'main' && s != 'master' && s != 'refs' && s != 'heads' && s != 'builds' && !s.endsWith('.json')) + .where( + (s) => + s != 'raw' && + s != 'main' && + s != 'master' && + s != 'refs' && + s != 'heads' && + s != 'builds' && + !s.endsWith('.json'), + ) .toList(); if (meaningful.isNotEmpty) { repoName = meaningful.last; @@ -391,17 +465,25 @@ class CloudStreamManager { bool isRepoInstalled(String rawUrl) { final normalized = normalizeRepoUrl(rawUrl).toLowerCase(); - return _repos.any((r) => normalizeRepoUrl(r.url).toLowerCase() == normalized); + return _repos.any( + (r) => normalizeRepoUrl(r.url).toLowerCase() == normalized, + ); } Future _saveRepos() async { final prefs = await SharedPreferences.getInstance(); - await prefs.setStringList(_reposKey, _repos.map((r) => r.encode()).toList()); + await prefs.setStringList( + _reposKey, + _repos.map((r) => r.encode()).toList(), + ); } Future _saveInstalled() async { final prefs = await SharedPreferences.getInstance(); - await prefs.setStringList(_installedKey, _installed.map((i) => i.encode()).toList()); + await prefs.setStringList( + _installedKey, + _installed.map((i) => i.encode()).toList(), + ); } // ── Available Extensions ────────────────────────────────────────────────── @@ -411,7 +493,9 @@ class CloudStreamManager { for (final repo in _repos) { try { - final res = await http.get(Uri.parse(repo.url)).timeout(const Duration(seconds: 12)); + final res = await http + .get(Uri.parse(repo.url)) + .timeout(const Duration(seconds: 12)); if (res.statusCode == 200) { final dynamic data = jsonDecode(res.body); if (data is List) { @@ -419,11 +503,15 @@ class CloudStreamManager { } else if (data is Map) { // Case 1: repo.json format with pluginLists if (data['pluginLists'] is List) { - final lists = (data['pluginLists'] as List).map((e) => e.toString()).toList(); + final lists = (data['pluginLists'] as List) + .map((e) => e.toString()) + .toList(); for (final listUrl in lists) { try { final normalizedListUrl = normalizeRepoUrl(listUrl); - final subRes = await http.get(Uri.parse(normalizedListUrl)).timeout(const Duration(seconds: 12)); + final subRes = await http + .get(Uri.parse(normalizedListUrl)) + .timeout(const Duration(seconds: 12)); if (subRes.statusCode == 200) { final dynamic subData = jsonDecode(subRes.body); if (subData is List) { @@ -431,7 +519,9 @@ class CloudStreamManager { } } } catch (e) { - debugPrint('[CloudStreamManager] Error fetching pluginList $listUrl: $e'); + debugPrint( + '[CloudStreamManager] Error fetching pluginList $listUrl: $e', + ); } } } @@ -465,7 +555,8 @@ class CloudStreamManager { a.name.toLowerCase() == inst.name.toLowerCase() || (inst.internalName != null && a.internalName != null && - a.internalName!.toLowerCase() == inst.internalName!.toLowerCase()) || + a.internalName!.toLowerCase() == + inst.internalName!.toLowerCase()) || (inst.internalName != null && a.name.toLowerCase() == inst.internalName!.toLowerCase()) || (a.internalName != null && @@ -485,11 +576,17 @@ class CloudStreamManager { if (updated) { await _saveInstalled(); - debugPrint('[CloudStreamManager] Reconciled ${_installed.length} installed extensions with official repository metadata.'); + debugPrint( + '[CloudStreamManager] Reconciled ${_installed.length} installed extensions with official repository metadata.', + ); } } - void _parsePluginList(List data, String repoTag, List target) { + void _parsePluginList( + List data, + String repoTag, + List target, + ) { for (final item in data) { if (item is Map) { final map = Map.from(item); @@ -570,7 +667,8 @@ class CloudStreamManager { busyMessage.value = 'Connecting installed extensions...'; await _connectRuntime(); await _saveInstalled(); - busyMessage.value = 'Installed $installedCount extension${installedCount == 1 ? '' : 's'} successfully!'; + busyMessage.value = + 'Installed $installedCount extension${installedCount == 1 ? '' : 's'} successfully!'; } finally { isBusy.value = false; } @@ -593,8 +691,12 @@ class CloudStreamManager { ); final repoPlugins = _available.where((p) { - final repoTagMatch = repoObj.name.isNotEmpty && p.repo?.toLowerCase() == repoObj.name.toLowerCase(); - final urlMatch = p.pluginUrl != null && p.pluginUrl!.toLowerCase().contains(normalized.toLowerCase()); + final repoTagMatch = + repoObj.name.isNotEmpty && + p.repo?.toLowerCase() == repoObj.name.toLowerCase(); + final urlMatch = + p.pluginUrl != null && + p.pluginUrl!.toLowerCase().contains(normalized.toLowerCase()); return repoTagMatch || urlMatch; }).toList(); @@ -683,9 +785,13 @@ class CloudStreamManager { final Map bridgeJson; if (isBridgeManifest) { - bridgeJson = jsonDecode(utf8.decode(metadataFile.content as List)); + bridgeJson = jsonDecode( + utf8.decode(metadataFile.content as List), + ); } else { - final csJson = jsonDecode(utf8.decode(metadataFile.content as List)); + final csJson = jsonDecode( + utf8.decode(metadataFile.content as List), + ); bridgeJson = { 'pluginClassName': csJson['pluginClassName'] ?? '', 'name': source.name, @@ -696,7 +802,9 @@ class CloudStreamManager { } final encoded = utf8.encode(jsonEncode(bridgeJson)); - finalArchive.addFile(ArchiveFile('manifest.json', encoded.length, encoded)); + finalArchive.addFile( + ArchiveFile('manifest.json', encoded.length, encoded), + ); final patchedJarBytes = ZipEncoder().encode(finalArchive); await File(finalJarPath).writeAsBytes(patchedJarBytes); @@ -708,9 +816,12 @@ class CloudStreamManager { } // Add to installed - _installed.removeWhere((e) => - e.name == source.name || - (source.internalName != null && e.internalName == source.internalName)); + _installed.removeWhere( + (e) => + e.name == source.name || + (source.internalName != null && + e.internalName == source.internalName), + ); _installed.add(source); await _saveInstalled(); } finally { @@ -766,12 +877,17 @@ class CloudStreamManager { await _deleteExtensionFiles(extDir, candidates); try { - await CloudStreamDispatcher.instance.unloadExtension(source.internalName ?? source.name); + await CloudStreamDispatcher.instance.unloadExtension( + source.internalName ?? source.name, + ); } catch (_) {} - _installed.removeWhere((e) => - e.name == source.name || - (source.internalName != null && e.internalName == source.internalName)); + _installed.removeWhere( + (e) => + e.name == source.name || + (source.internalName != null && + e.internalName == source.internalName), + ); _loadedProviders.removeWhere((p) { final pId = (p['id'] ?? '').toString().toLowerCase(); final pName = (p['name'] ?? '').toString().toLowerCase(); @@ -792,12 +908,17 @@ class CloudStreamManager { await _deleteExtensionFiles(extDir, candidates); try { - await CloudStreamDispatcher.instance.unloadExtension(source.internalName ?? source.name); + await CloudStreamDispatcher.instance.unloadExtension( + source.internalName ?? source.name, + ); } catch (_) {} - _installed.removeWhere((e) => - e.name == source.name || - (source.internalName != null && e.internalName == source.internalName)); + _installed.removeWhere( + (e) => + e.name == source.name || + (source.internalName != null && + e.internalName == source.internalName), + ); _loadedProviders.removeWhere((p) { final pId = (p['id'] ?? '').toString().toLowerCase(); final pName = (p['name'] ?? '').toString().toLowerCase(); @@ -823,7 +944,10 @@ class CloudStreamManager { final javaBinDir = p.dirname(javaPath); final env = Map.from(Platform.environment); - final pathKey = env.keys.firstWhere((k) => k.toUpperCase() == 'PATH', orElse: () => 'PATH'); + final pathKey = env.keys.firstWhere( + (k) => k.toUpperCase() == 'PATH', + orElse: () => 'PATH', + ); final separator = Platform.isWindows ? ';' : ':'; env[pathKey] = '$javaBinDir$separator${env[pathKey] ?? ''}'; @@ -839,7 +963,9 @@ class CloudStreamManager { ); if (process.exitCode != 0) { - throw Exception('dex2jar compilation failed: ${process.stderr}\n${process.stdout}'); + throw Exception( + 'dex2jar compilation failed: ${process.stderr}\n${process.stdout}', + ); } } @@ -852,7 +978,9 @@ class CloudStreamManager { void cancelActiveScrapes() { _scrapeSessionId++; if (_activeScrapeSubscriptions.isNotEmpty) { - debugPrint('[CloudStreamManager] Cancelling ${_activeScrapeSubscriptions.length} active extension scrapes (session: $_scrapeSessionId)...'); + debugPrint( + '[CloudStreamManager] Cancelling ${_activeScrapeSubscriptions.length} active extension scrapes (session: $_scrapeSessionId)...', + ); final subs = List.from(_activeScrapeSubscriptions); _activeScrapeSubscriptions.clear(); for (final sub in subs) { @@ -891,43 +1019,61 @@ class CloudStreamManager { for (final ext in active) { if (!ext.supportsType(type, genres: genres)) continue; - final sanitizedName = ext.name.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); + final sanitizedName = ext.name.toLowerCase().replaceAll( + RegExp(r'[^a-z0-9]'), + '', + ); // Find matches in _loadedProviders using resilient fuzzy/className matcher final matched = findLoadedProviders(ext); if (matched.isNotEmpty) { for (final m in matched) { - final sourceId = m['id']?.toString() ?? (m['internalName']?.toString() ?? 'cs_$sanitizedName'); + final sourceId = + m['id']?.toString() ?? + (m['internalName']?.toString() ?? 'cs_$sanitizedName'); final providerName = m['name']?.toString() ?? ext.name; - targets.add(_ProviderTarget( - sourceId: sourceId, - providerName: providerName, - isAnimeProvider: ext.supportsAnime, - )); + targets.add( + _ProviderTarget( + sourceId: sourceId, + providerName: providerName, + isAnimeProvider: ext.supportsAnime, + ), + ); } } else { // Fallback if not mapped yet final fallbackId = ext.internalName ?? 'cs_$sanitizedName'; - targets.add(_ProviderTarget( - sourceId: fallbackId, - providerName: ext.name, - isAnimeProvider: ext.supportsAnime, - )); - if (ext.internalName != null && ext.internalName != ext.name) { - targets.add(_ProviderTarget( - sourceId: ext.internalName!, + targets.add( + _ProviderTarget( + sourceId: fallbackId, providerName: ext.name, isAnimeProvider: ext.supportsAnime, - )); + ), + ); + if (ext.internalName != null && ext.internalName != ext.name) { + targets.add( + _ProviderTarget( + sourceId: ext.internalName!, + providerName: ext.name, + isAnimeProvider: ext.supportsAnime, + ), + ); } - final stripped = ext.name.replaceAll(RegExp(r'Provider|Plugin|Scraper', caseSensitive: false), '').trim(); + final stripped = ext.name + .replaceAll( + RegExp(r'Provider|Plugin|Scraper', caseSensitive: false), + '', + ) + .trim(); if (stripped.isNotEmpty && stripped != ext.name) { - targets.add(_ProviderTarget( - sourceId: stripped, - providerName: ext.name, - isAnimeProvider: ext.supportsAnime, - )); + targets.add( + _ProviderTarget( + sourceId: stripped, + providerName: ext.name, + isAnimeProvider: ext.supportsAnime, + ), + ); } } } @@ -937,7 +1083,9 @@ class CloudStreamManager { targets.removeWhere((t) => !seenTargetIds.add(t.sourceId.toLowerCase())); if (targets.isEmpty) { - debugPrint('[CloudStreamManager] scrapeStreams: 0 targets found for type "$type" (active extensions: ${active.length})'); + debugPrint( + '[CloudStreamManager] scrapeStreams: 0 targets found for type "$type" (active extensions: ${active.length})', + ); controller.close(); return controller.stream; } @@ -954,38 +1102,43 @@ class CloudStreamManager { CloudStreamDispatcher.instance.cancelOngoingRequests(); }; - debugPrint('[CloudStreamManager] scrapeStreams: Scraping "$title" (type: $type, S${season ?? "-"}:E${episode ?? "-"}, year: $year) across ${targets.length} targets: [${targets.map((t) => t.providerName).join(", ")}] (session: $currentSession)'); + debugPrint( + '[CloudStreamManager] scrapeStreams: Scraping "$title" (type: $type, S${season ?? "-"}:E${episode ?? "-"}, year: $year) across ${targets.length} targets: [${targets.map((t) => t.providerName).join(", ")}] (session: $currentSession)', + ); int pending = targets.length; for (final target in targets) { - final sub = _scrapeSingleExtension( - sourceId: target.sourceId, - providerName: target.providerName, - isAnimeProvider: target.isAnimeProvider, - type: type, - title: title, - year: year, - season: season, - episode: episode, - alternativeTitles: alternativeTitles, - sessionId: currentSession, - ).listen( - (src) { - if (!controller.isClosed && currentSession == _scrapeSessionId) { - controller.add(src); - } - }, - onError: (err) { - debugPrint('[CloudStreamManager] [${target.providerName}] Stream error: $err'); - }, - onDone: () { - pending--; - if (pending <= 0 && !controller.isClosed) { - controller.close(); - } - }, - ); + final sub = + _scrapeSingleExtension( + sourceId: target.sourceId, + providerName: target.providerName, + isAnimeProvider: target.isAnimeProvider, + type: type, + title: title, + year: year, + season: season, + episode: episode, + alternativeTitles: alternativeTitles, + sessionId: currentSession, + ).listen( + (src) { + if (!controller.isClosed && currentSession == _scrapeSessionId) { + controller.add(src); + } + }, + onError: (err) { + debugPrint( + '[CloudStreamManager] [${target.providerName}] Stream error: $err', + ); + }, + onDone: () { + pending--; + if (pending <= 0 && !controller.isClosed) { + controller.close(); + } + }, + ); sessionSubs.add(sub); _activeScrapeSubscriptions.add(sub); @@ -1009,7 +1162,8 @@ class CloudStreamManager { if (sessionId != null && sessionId != _scrapeSessionId) return; try { final isAnime = type == 'anime' || isAnimeProvider; - final isSeries = type == 'series' || type == 'tv' || type == 'show' || isAnime; + final isSeries = + type == 'series' || type == 'tv' || type == 'show' || isAnime; final queries = [ title, if (alternativeTitles != null) ...alternativeTitles, @@ -1021,54 +1175,77 @@ class CloudStreamManager { queries.add('$title S$season'); } - final uniqueQueries = queries.where((q) => q.trim().isNotEmpty).toSet().toList(); + final uniqueQueries = queries + .where((q) => q.trim().isNotEmpty) + .toSet() + .toList(); Map? bestMatch; for (final query in uniqueQueries) { if (sessionId != null && sessionId != _scrapeSessionId) return; - debugPrint('[CloudStreamManager] [$providerName] Searching for "$query"...'); - final searchResult = await CloudStreamDispatcher.instance.search( - sourceId: sourceId, - query: query, - ).timeout(const Duration(seconds: 8), onTimeout: () => null); + debugPrint( + '[CloudStreamManager] [$providerName] Searching for "$query"...', + ); + final searchResult = await CloudStreamDispatcher.instance + .search(sourceId: sourceId, query: query) + .timeout(const Duration(seconds: 8), onTimeout: () => null); if (sessionId != null && sessionId != _scrapeSessionId) return; List? items; if (searchResult is List) { items = searchResult; } else if (searchResult is Map) { - items = (searchResult['list'] ?? searchResult['results'] ?? searchResult['data']) as List?; + items = + (searchResult['list'] ?? + searchResult['results'] ?? + searchResult['data']) + as List?; } else if (searchResult is String && searchResult.trim().isNotEmpty) { try { final decoded = jsonDecode(searchResult); if (decoded is List) { items = decoded; } else if (decoded is Map) { - items = (decoded['list'] ?? decoded['results'] ?? decoded['data']) as List?; + items = + (decoded['list'] ?? decoded['results'] ?? decoded['data']) + as List?; } } catch (_) {} } if (items == null || items.isEmpty) { - debugPrint('[CloudStreamManager] [$providerName] No items returned for "$query"'); + debugPrint( + '[CloudStreamManager] [$providerName] No items returned for "$query"', + ); continue; } final cleanQuery = query.trim().toLowerCase(); final cleanQueryAlpha = cleanQuery.replaceAll(RegExp(r'[^a-z0-9]'), ''); - final cleanQueryNoThe = cleanQuery.startsWith('the ') ? cleanQuery.substring(4).trim() : cleanQuery; + final cleanQueryNoThe = cleanQuery.startsWith('the ') + ? cleanQuery.substring(4).trim() + : cleanQuery; for (final item in items) { if (item is Map) { final itemMap = Map.from(item); - final itTitle = (itemMap['title'] ?? itemMap['name'] ?? '').toString().trim().toLowerCase(); + final itTitle = (itemMap['title'] ?? itemMap['name'] ?? '') + .toString() + .trim() + .toLowerCase(); final itTitleAlpha = itTitle.replaceAll(RegExp(r'[^a-z0-9]'), ''); - final itTitleNoThe = itTitle.startsWith('the ') ? itTitle.substring(4).trim() : itTitle; + final itTitleNoThe = itTitle.startsWith('the ') + ? itTitle.substring(4).trim() + : itTitle; // For TV series, do NOT reject based on episode release year vs series debut year final itYear = itemMap['year'] is num ? (itemMap['year'] as num).toInt() : int.tryParse(itemMap['year']?.toString() ?? ''); - if (!isAnime && !isSeries && year != null && itYear != null && (itYear - year).abs() > 1) { + if (!isAnime && + !isSeries && + year != null && + itYear != null && + (itYear - year).abs() > 1) { continue; } @@ -1076,7 +1253,8 @@ class CloudStreamManager { if (itTitle == cleanQuery || itTitleAlpha == cleanQueryAlpha || itTitleNoThe == cleanQueryNoThe || - (itTitleAlpha.isNotEmpty && cleanQueryAlpha.isNotEmpty && + (itTitleAlpha.isNotEmpty && + cleanQueryAlpha.isNotEmpty && (itTitleAlpha == cleanQueryAlpha || itTitleAlpha.startsWith(cleanQueryAlpha) || cleanQueryAlpha.startsWith(itTitleAlpha)))) { @@ -1100,7 +1278,9 @@ class CloudStreamManager { // If no valid title matched, DO NOT return random recommendations if (bestMatch == null) { - debugPrint('[CloudStreamManager] [$providerName] No matching show found for "$title"'); + debugPrint( + '[CloudStreamManager] [$providerName] No matching show found for "$title"', + ); return; } @@ -1112,43 +1292,58 @@ class CloudStreamManager { if (sessionId != null && sessionId != _scrapeSessionId) return; final matchTitle = bestMatch['title'] ?? bestMatch['name'] ?? title; - debugPrint('[CloudStreamManager] [$providerName] Matched "$matchTitle". Fetching details...'); + debugPrint( + '[CloudStreamManager] [$providerName] Matched "$matchTitle". Fetching details...', + ); // 2. Fetch media details - final detailsRes = await CloudStreamDispatcher.instance.getDetail( - sourceId: sourceId, - url: mediaUrl, - ).timeout(const Duration(seconds: 10), onTimeout: () => null); + final detailsRes = await CloudStreamDispatcher.instance + .getDetail(sourceId: sourceId, url: mediaUrl) + .timeout(const Duration(seconds: 10), onTimeout: () => null); if (sessionId != null && sessionId != _scrapeSessionId) return; - final detailsMap = detailsRes is Map ? detailsRes : (detailsRes is String ? jsonDecode(detailsRes) : null); + final detailsMap = detailsRes is Map + ? detailsRes + : (detailsRes is String ? jsonDecode(detailsRes) : null); if (detailsMap == null) { - debugPrint('[CloudStreamManager] [$providerName] Failed to parse details map'); + debugPrint( + '[CloudStreamManager] [$providerName] Failed to parse details map', + ); return; } String? targetEpisodeDataUrl; if (isSeries && episode != null) { - final episodesList = (detailsMap['episodes'] as List?) ?? - (detailsMap['extraData'] is Map ? (detailsMap['extraData'] as Map)['episodes'] as List? : null); - debugPrint('[CloudStreamManager] [$providerName] Detail has ${episodesList?.length ?? 0} episode(s). Matching S${season ?? 1}:E$episode...'); + final episodesList = + (detailsMap['episodes'] as List?) ?? + (detailsMap['extraData'] is Map + ? (detailsMap['extraData'] as Map)['episodes'] as List? + : null); + debugPrint( + '[CloudStreamManager] [$providerName] Detail has ${episodesList?.length ?? 0} episode(s). Matching S${season ?? 1}:E$episode...', + ); if (episodesList != null && episodesList.isNotEmpty) { for (int i = 0; i < episodesList.length; i++) { final ep = episodesList[i]; if (ep is Map) { - final epNum = _extractEpisodeNumber(ep) ?? (episodesList.length == 1 ? 1 : null); + final epNum = + _extractEpisodeNumber(ep) ?? + (episodesList.length == 1 ? 1 : null); final sNum = _extractSeasonNumber(ep); - final seasonMatches = isAnime || + final seasonMatches = + isAnime || season == null || (sNum != null && sNum == season) || (sNum == null && (season == 1 || episodesList.length <= 30)); if (epNum == episode && seasonMatches) { targetEpisodeDataUrl = _extractEpisodeStreamUrl(ep); - debugPrint('[CloudStreamManager] [$providerName] Matched S${sNum ?? season ?? 1}:E$epNum ("${ep['name'] ?? 'Episode $epNum'}") -> ${redactUrl(targetEpisodeDataUrl)}'); + debugPrint( + '[CloudStreamManager] [$providerName] Matched S${sNum ?? season ?? 1}:E$epNum ("${ep['name'] ?? 'Episode $epNum'}") -> ${redactUrl(targetEpisodeDataUrl)}', + ); break; } } @@ -1157,28 +1352,40 @@ class CloudStreamManager { } else { // Movie final episodesList = detailsMap['episodes'] as List?; - if (episodesList != null && episodesList.isNotEmpty && episodesList.first is Map) { - targetEpisodeDataUrl = _extractEpisodeStreamUrl(episodesList.first as Map); + if (episodesList != null && + episodesList.isNotEmpty && + episodesList.first is Map) { + targetEpisodeDataUrl = _extractEpisodeStreamUrl( + episodesList.first as Map, + ); } - targetEpisodeDataUrl ??= detailsMap['dataUrl']?.toString() ?? + targetEpisodeDataUrl ??= + detailsMap['dataUrl']?.toString() ?? detailsMap['data']?.toString() ?? - (detailsMap['extraData'] is Map ? (detailsMap['extraData'] as Map)['url']?.toString() : null) ?? + (detailsMap['extraData'] is Map + ? (detailsMap['extraData'] as Map)['url']?.toString() + : null) ?? mediaUrl; } if (targetEpisodeDataUrl == null || targetEpisodeDataUrl.isEmpty) { - debugPrint('[CloudStreamManager] [$providerName] No episode dataUrl found for S${season ?? 1}:E${episode ?? 1}'); + debugPrint( + '[CloudStreamManager] [$providerName] No episode dataUrl found for S${season ?? 1}:E${episode ?? 1}', + ); return; } if (sessionId != null && sessionId != _scrapeSessionId) return; - debugPrint('[CloudStreamManager] [$providerName] Resolving video streams for "${redactUrl(targetEpisodeDataUrl)}"...'); + debugPrint( + '[CloudStreamManager] [$providerName] Resolving video streams for "${redactUrl(targetEpisodeDataUrl)}"...', + ); // 3. Stream links - await for (final linkData in CloudStreamDispatcher.instance.getVideoListStream( - sourceId: sourceId, - url: targetEpisodeDataUrl, - )) { + await for (final linkData + in CloudStreamDispatcher.instance.getVideoListStream( + sourceId: sourceId, + url: targetEpisodeDataUrl, + )) { if (sessionId != null && sessionId != _scrapeSessionId) return; Map? dataMap; if (linkData is Map) { @@ -1195,13 +1402,17 @@ class CloudStreamManager { mediaTitle: title, ); if (source != null) { - debugPrint('[CloudStreamManager] [$providerName] Yielded stream: ${source.name}'); + debugPrint( + '[CloudStreamManager] [$providerName] Yielded stream: ${source.name}', + ); yield source; } } } } catch (e) { - debugPrint('[CloudStreamManager] Error scraping $providerName ($sourceId): $e'); + debugPrint( + '[CloudStreamManager] Error scraping $providerName ($sourceId): $e', + ); } } @@ -1213,21 +1424,26 @@ class CloudStreamManager { } static int? _extractEpisodeNumber(Map ep) { - int? epNum = _parseNumber(ep['episodeNumber']) ?? + int? epNum = + _parseNumber(ep['episodeNumber']) ?? _parseNumber(ep['episode']) ?? _parseNumber(ep['epNum']); if (epNum != null) return epNum; if (ep['extraData'] is Map) { final extra = ep['extraData'] as Map; - epNum = _parseNumber(extra['episodeNumber']) ?? + epNum = + _parseNumber(extra['episodeNumber']) ?? _parseNumber(extra['episode']) ?? _parseNumber(extra['epNum']); if (epNum != null) return epNum; } final name = (ep['name'] ?? ep['title'] ?? '').toString(); - final match = RegExp(r'\b(?:ep|episode|e)\s*0*(\d+)\b', caseSensitive: false).firstMatch(name); + final match = RegExp( + r'\b(?:ep|episode|e)\s*0*(\d+)\b', + caseSensitive: false, + ).firstMatch(name); if (match != null) { return int.tryParse(match.group(1)!); } @@ -1236,21 +1452,26 @@ class CloudStreamManager { } static int? _extractSeasonNumber(Map ep) { - int? sNum = _parseNumber(ep['season']) ?? + int? sNum = + _parseNumber(ep['season']) ?? _parseNumber(ep['seasonNumber']) ?? _parseNumber(ep['season_number']); if (sNum != null) return sNum; if (ep['extraData'] is Map) { final extra = ep['extraData'] as Map; - sNum = _parseNumber(extra['season']) ?? + sNum = + _parseNumber(extra['season']) ?? _parseNumber(extra['seasonNumber']) ?? _parseNumber(extra['season_number']); if (sNum != null) return sNum; } final name = (ep['name'] ?? ep['title'] ?? '').toString(); - final match = RegExp(r'\b(?:season|s)\s*0*(\d+)\b', caseSensitive: false).firstMatch(name); + final match = RegExp( + r'\b(?:season|s)\s*0*(\d+)\b', + caseSensitive: false, + ).firstMatch(name); if (match != null) { return int.tryParse(match.group(1)!); } @@ -1289,7 +1510,8 @@ class CloudStreamManager { Future>>> searchAcrossExtensions( String query, { int limitPerExtension = 15, - void Function(String providerName, List> items)? onProviderResult, + void Function(String providerName, List> items)? + onProviderResult, void Function(String source, Object error)? onSourceError, }) async { final cleanQuery = query.trim(); @@ -1304,12 +1526,17 @@ class CloudStreamManager { } catch (_) {} } - debugPrint('[CloudStreamManager] searchAcrossExtensions: "$cleanQuery" across ${active.length} extensions'); + debugPrint( + '[CloudStreamManager] searchAcrossExtensions: "$cleanQuery" across ${active.length} extensions', + ); final Map>> results = {}; final futures = active.map((ext) async { try { - final sanitizedName = ext.name.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); + final sanitizedName = ext.name.toLowerCase().replaceAll( + RegExp(r'[^a-z0-9]'), + '', + ); String sourceId = ext.internalName ?? 'cs_$sanitizedName'; final matched = findLoadedProvider(ext); @@ -1319,22 +1546,23 @@ class CloudStreamManager { // A null response here is a dead sidecar runtime, not an extension that // matched nothing, so it is reported rather than silently skipped. - final res = await dispatcher.search( - sourceId: sourceId, - query: cleanQuery, - ).timeout( - const Duration(seconds: 6), - // A timeout is reported here and resolves to nothing below: this - // extension already has its one report, and falling through to the - // null-response branch would blame it twice for a single fault. - onTimeout: () { - onSourceError?.call( - ext.name, - TimeoutException('CloudStream search timed out for ${ext.name}'), + final res = await dispatcher + .search(sourceId: sourceId, query: cleanQuery) + .timeout( + const Duration(seconds: 6), + // A timeout is reported here and resolves to nothing below: this + // extension already has its one report, and falling through to the + // null-response branch would blame it twice for a single fault. + onTimeout: () { + onSourceError?.call( + ext.name, + TimeoutException( + 'CloudStream search timed out for ${ext.name}', + ), + ); + return _searchTimedOut; + }, ); - return _searchTimedOut; - }, - ); if (identical(res, _searchTimedOut)) return; @@ -1342,7 +1570,9 @@ class CloudStreamManager { if (map == null) { onSourceError?.call( ext.name, - StateError('CloudStream dispatcher returned no response for ${ext.name}'), + StateError( + 'CloudStream dispatcher returned no response for ${ext.name}', + ), ); return; } @@ -1362,7 +1592,9 @@ class CloudStreamManager { if (items.isNotEmpty) { results[ext.name] = items; - debugPrint('[CloudStreamManager] [${ext.name}] Search returned ${items.length} items'); + debugPrint( + '[CloudStreamManager] [${ext.name}] Search returned ${items.length} items', + ); onProviderResult?.call(ext.name, items); } } catch (e) { @@ -1383,26 +1615,38 @@ class CloudStreamManager { String? fallbackPoster, }) async { try { - debugPrint('[CloudStreamManager] fetchCloudStreamDetail: sourceId=$sourceId, url=${redactUrl(mediaUrl)}'); - final res = await CloudStreamDispatcher.instance.getDetail( - sourceId: sourceId, - url: mediaUrl, - ).timeout(const Duration(seconds: 10), onTimeout: () => null); + debugPrint( + '[CloudStreamManager] fetchCloudStreamDetail: sourceId=$sourceId, url=${redactUrl(mediaUrl)}', + ); + final res = await CloudStreamDispatcher.instance + .getDetail(sourceId: sourceId, url: mediaUrl) + .timeout(const Duration(seconds: 10), onTimeout: () => null); - final map = res is Map ? Map.from(res) : (res is String ? jsonDecode(res) as Map : null); + final map = res is Map + ? Map.from(res) + : (res is String ? jsonDecode(res) as Map : null); if (map == null) return null; - final title = map['title']?.toString() ?? fallbackTitle ?? 'Unknown Title'; - final poster = map['cover']?.toString() ?? map['poster']?.toString() ?? fallbackPoster; - final description = map['description']?.toString() ?? map['plot']?.toString(); + final title = + map['title']?.toString() ?? fallbackTitle ?? 'Unknown Title'; + final poster = + map['cover']?.toString() ?? + map['poster']?.toString() ?? + fallbackPoster; + final description = + map['description']?.toString() ?? map['plot']?.toString(); final year = map['year']?.toString(); - final genres = (map['genre'] as List?)?.map((e) => e.toString()).toList() ?? + final genres = + (map['genre'] as List?)?.map((e) => e.toString()).toList() ?? (map['genres'] as List?)?.map((e) => e.toString()).toList() ?? []; final cast = []; - final rawActors = (map['actors'] as List?) ?? - (map['extraData'] is Map ? (map['extraData'] as Map)['actors'] as List? : null); + final rawActors = + (map['actors'] as List?) ?? + (map['extraData'] is Map + ? (map['extraData'] as Map)['actors'] as List? + : null); if (rawActors != null) { for (final act in rawActors) { if (act is Map && act['actor'] is Map) { @@ -1416,8 +1660,11 @@ class CloudStreamManager { } } - final rawEpisodes = (map['episodes'] as List?) ?? - (map['extraData'] is Map ? (map['extraData'] as Map)['episodes'] as List? : null); + final rawEpisodes = + (map['episodes'] as List?) ?? + (map['extraData'] is Map + ? (map['extraData'] as Map)['episodes'] as List? + : null); final List