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/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/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`. diff --git a/lib/pages/audiobooks/audiobook_player_screen.dart b/lib/pages/audiobooks/audiobook_player_screen.dart index fcc6e7bc..78108735 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(); }); } @@ -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': @@ -2030,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, @@ -2265,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 bef46919..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( @@ -48,10 +45,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; @@ -218,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, + ), ), ), ), @@ -245,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, ), @@ -273,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, ), @@ -336,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, @@ -357,7 +375,9 @@ class _BookDetailSheetState extends State { Navigator.pop(context); Navigator.push( context, - MaterialPageRoute(builder: (_) => const GenerateAudiobookScreen()), + MaterialPageRoute( + builder: (_) => const GenerateAudiobookScreen(), + ), ); }, ), @@ -388,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'), @@ -398,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 bbfd24f6..9e8b7541 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,14 +169,19 @@ class _EpubReaderPageState extends State { _scrollController.jumpTo(0); } + _progressSaveTimer?.cancel(); _saveProgress(); } 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, @@ -180,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, @@ -194,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'; @@ -203,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); @@ -220,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)}...'; } @@ -266,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, @@ -297,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(), @@ -309,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 ── @@ -369,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, @@ -400,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, @@ -425,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; @@ -580,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; @@ -616,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: [ @@ -647,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, ), ], ), @@ -672,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, ), ), @@ -682,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, ), ), @@ -692,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, ), ), @@ -710,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', @@ -726,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')), ], ), ], @@ -745,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), ), ], ), @@ -809,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/home/home_page.dart b/lib/pages/home/home_page.dart index 788f3217..49b0cb5b 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(); @@ -733,6 +716,8 @@ class _HomePageState extends State { ..addAll(fetched.whereType()); _traktPublicLoaded = true; }); + } catch (e) { + debugPrint('[HomePage] Trakt public sections failed: $e'); } finally { _traktPublicInFlight = false; } @@ -905,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/iptv/iptv_portal_browser_page.dart b/lib/pages/iptv/iptv_portal_browser_page.dart index 30fdc0d2..73044c29 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 = ''; @@ -91,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); @@ -131,6 +133,7 @@ class _IptvPortalBrowserPageState extends State { @override void dispose() { _cancelAlive = true; + _searchDebounce?.cancel(); IptvSettings.changeNotifier.removeListener(_onSettingsChanged); AppThemeService.currentPalette.removeListener(_onSettingsChanged); _categoryScrollController.removeListener(_updateCategoryScrollState); @@ -166,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), ), ], @@ -203,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, _) { @@ -211,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( @@ -229,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), @@ -245,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), ); }, ), @@ -258,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), @@ -271,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), ); }, ), @@ -290,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(() { @@ -303,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(() { @@ -315,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), @@ -326,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), @@ -349,9 +397,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; @@ -394,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; @@ -442,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(); @@ -514,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; @@ -527,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.', + ), ), ); } @@ -548,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) { @@ -561,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) { @@ -576,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], @@ -603,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, @@ -634,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', @@ -688,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, @@ -714,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 = ''); @@ -769,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); @@ -783,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; @@ -839,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( @@ -858,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), @@ -866,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, + ), ), ], ), @@ -891,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), @@ -907,15 +1065,28 @@ 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(); setState(() => _searchQuery = ''); }, @@ -923,368 +1094,458 @@ 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, ), ), - onChanged: (v) => setState(() => _searchQuery = v), + onChanged: _onSearchChanged, ), ), // 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: () { - _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: (v) => setState(() => _searchQuery = v), ), - ), - ], + ], + ), ), - ), - // ── 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), + ), ), ), ], @@ -1292,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(), + ), + ], + ), ), ), - ), ); } @@ -1341,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, + ), ), ], ), @@ -1363,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( @@ -1418,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, @@ -1451,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) { @@ -1482,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) { @@ -1529,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, @@ -1583,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( @@ -1604,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( @@ -1629,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) ...[ @@ -1681,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 (_) {} @@ -1727,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; @@ -1775,7 +2134,7 @@ class _LiveChannelListRowState extends State<_LiveChannelListRow> { ), const SizedBox(width: ZplaySpacing.s8), ], - + // ── CHANNEL LOGO BAY ── if (showLogo) ...[ Container( @@ -1794,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. @@ -1834,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) ...[ @@ -1843,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, + ), ), ], @@ -1855,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, + ), ), ], ], @@ -1884,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, @@ -1897,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`). @@ -1926,7 +2312,7 @@ class _LiveChannelListRowState extends State<_LiveChannelListRow> { ), ), ), - + const SizedBox(width: ZplaySpacing.s4), // Favorite Button @@ -1935,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, ), ), @@ -1963,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, @@ -2057,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 @@ -2072,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, + ), ), ), @@ -2112,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, ), ), @@ -2145,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( @@ -2159,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( @@ -2200,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; @@ -2234,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) ...[ @@ -2249,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, + ), + ), ), ), ], @@ -2259,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) @@ -2290,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, ), ), @@ -2369,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) @@ -2380,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, + ), ), ), ), @@ -2395,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, ), ), @@ -2454,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; @@ -2495,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, @@ -2523,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, @@ -2551,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), ), ), @@ -2585,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 2d05f82c..77b1f083 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,7 +53,9 @@ class _MangaReaderPageState extends State { PageController? _pageController; final ScrollController _verticalScrollController = ScrollController(); final ScrollController _deckScrollController = ScrollController(); - final TransformationController _transformationController = TransformationController(); + Timer? _progressSaveTimer; + final TransformationController _transformationController = + TransformationController(); double _currentZoom = 1.0; @@ -87,6 +90,8 @@ class _MangaReaderPageState extends State { @override void dispose() { + _progressSaveTimer?.cancel(); + _saveProgress(); MangaSettings.changeNotifier.removeListener(_onSettingsChanged); AppThemeService.currentPalette.removeListener(_onSettingsChanged); _focusNode.dispose(); @@ -115,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, + ); } }); } @@ -128,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, + ); } }); } @@ -147,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); @@ -162,22 +177,33 @@ 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. - 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, - ); + // 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(); + 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; @@ -185,7 +211,10 @@ class _MangaReaderPageState extends State { ResizeImage.resizeIfNeeded( pageCacheWidth, null, - CachedNetworkImageProvider(urls[i], cacheManager: AppImageCache.manager), + CachedNetworkImageProvider( + urls[i], + cacheManager: AppImageCache.manager, + ), ), context, ); @@ -211,8 +240,17 @@ class _MangaReaderPageState extends State { ); } + void _scheduleSaveProgress() { + _progressSaveTimer?.cancel(); + _progressSaveTimer = Timer(const Duration(seconds: 2), _saveProgress); + } + 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; @@ -229,18 +267,21 @@ 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); - _saveProgress(); + _scheduleSaveProgress(); _scrollToCurrentPageInDeck(); } } void _onPageChanged(int index) { setState(() => _currentPageIndex = index); - _saveProgress(); + _scheduleSaveProgress(); _scrollToCurrentPageInDeck(); } @@ -252,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, ); @@ -262,7 +306,7 @@ class _MangaReaderPageState extends State { _pageController!.jumpToPage(pageIndex); } } - _saveProgress(); + _scheduleSaveProgress(); _scrollToCurrentPageInDeck(); } @@ -316,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(); } @@ -331,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(); } @@ -368,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', @@ -384,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), @@ -395,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', @@ -413,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, @@ -435,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), @@ -446,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), @@ -459,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), ); }, ), @@ -497,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) @@ -591,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, + ), + ), + ), ), ); }, @@ -637,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, + ), ), - )), + ), + ), ); }, ), @@ -663,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( @@ -694,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), @@ -703,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( @@ -714,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, + ), ), ), ], @@ -742,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, @@ -772,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', @@ -783,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, @@ -793,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), ), ), ], @@ -819,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), ), @@ -842,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, @@ -872,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), ), ), ), @@ -911,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, @@ -1022,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, @@ -1050,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 @@ -1082,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 @@ -1090,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), ), ), ), @@ -1123,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), ), ), @@ -1221,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 a165408c..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)), ), ), ), @@ -4193,15 +4451,21 @@ 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()), + 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, + ), ), ), ), @@ -4235,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)), ), ), @@ -4295,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), ), @@ -4370,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, ), ], @@ -4433,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, ), @@ -4460,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, ), ), @@ -4472,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( @@ -4484,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), ), @@ -4495,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( @@ -4509,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, ), ), @@ -4518,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; @@ -4548,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), @@ -4565,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), @@ -4578,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( @@ -4603,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, + ), ), ), ), @@ -4630,7 +4933,8 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT child: CachedNetworkImage( imageUrl: track.coverUrl, cacheManager: AppImageCache.manager, - fit: BoxFit.cover), + fit: BoxFit.cover, + ), ), ); } @@ -4660,7 +4964,8 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT child: CachedNetworkImage( imageUrl: track.coverUrl, cacheManager: AppImageCache.manager, - fit: BoxFit.cover), + fit: BoxFit.cover, + ), ), ), ); @@ -4674,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) { @@ -4705,7 +5015,8 @@ class _MusicExpandedPlayerState extends State<_MusicExpandedPlayer> with SingleT child: CachedNetworkImage( imageUrl: track.coverUrl, cacheManager: AppImageCache.manager, - fit: BoxFit.cover), + fit: BoxFit.cover, + ), ), ); } @@ -4750,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', @@ -4758,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, ), ], @@ -4766,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: [ @@ -4782,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, + ), ), ], ), @@ -4813,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 = ''; @@ -4836,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; @@ -4889,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, ), ], @@ -4923,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); }, ), @@ -4933,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); }, ), @@ -4945,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( @@ -4982,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, ), @@ -5001,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( @@ -5019,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, ), @@ -5040,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, ), @@ -5052,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, + ), ), ], ), @@ -5074,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(() {}); } }, @@ -5111,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); }, ); @@ -5141,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, @@ -5152,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; @@ -5169,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, @@ -5206,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/pages/player/player_screen.dart b/lib/pages/player/player_screen.dart index ab31d47c..8755eee5 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. @@ -62,6 +64,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 +1086,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 +1154,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...'); @@ -1192,7 +1197,8 @@ class _PlayerScreenState extends State { 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(cleanUri.toString())} ' + '(headers: ${playerHeaders.keys})', ); if (!mounted) return; @@ -1258,7 +1264,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); @@ -1322,7 +1328,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) { @@ -1339,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: "$streamUrl"', + '[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; @@ -1390,11 +1398,11 @@ 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, - context: '${e.service} ${e.kind.name} ${e.detail}', + redactTextUrls('$e'), + 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; @@ -2793,11 +2801,11 @@ class _PlayerScreenState extends State { // 5. Critical error on dead stream CrashBreadcrumbs.error( - errorMsg, + redactTextUrls(errorMsg), 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'); @@ -2905,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(); @@ -3870,14 +3880,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(), + ), ); }, ), @@ -3990,9 +4002,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, ), ), @@ -4020,9 +4033,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/services/anime/extractors/tryembed_extractor.dart b/lib/services/anime/extractors/tryembed_extractor.dart index 05cf2ea0..8aded398 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. @@ -66,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); @@ -77,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}'); @@ -91,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() => @@ -117,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; } @@ -135,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)); } @@ -166,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; @@ -194,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 2efc9200..008145f7 100644 --- a/lib/services/anime_arabic/mega_proxy.dart +++ b/lib/services/anime_arabic/mega_proxy.dart @@ -5,6 +5,9 @@ import 'dart:io'; 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; final int size; @@ -34,12 +37,29 @@ 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); - 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; @@ -50,7 +70,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; @@ -72,12 +92,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, @@ -87,7 +110,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; } } @@ -103,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); @@ -122,13 +149,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; @@ -136,8 +170,22 @@ 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'); @@ -149,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()); @@ -179,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(); @@ -212,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; @@ -231,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 08cc3780..2a8a3703 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 { @@ -28,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); } @@ -39,13 +45,21 @@ 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. 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(); @@ -56,6 +70,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; @@ -68,8 +83,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); @@ -91,19 +109,22 @@ class BookDownloadService { Exception? lastException; final client = HttpClient(); - client.badCertificateCallback = (cert, host, port) => true; for (final urlStr in candidateUrls) { 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(); @@ -173,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 553c6be7..69d474b5 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._(); @@ -41,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) { @@ -71,12 +72,17 @@ 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; } 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 && @@ -94,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 a80c4b4d..ca6c863b 100644 --- a/lib/services/cloudstream/cloudstream_manager.dart +++ b/lib/services/cloudstream/cloudstream_manager.dart @@ -13,6 +13,8 @@ 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'; import 'runtime/cloudstream_dispatcher.dart'; @@ -51,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; @@ -79,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; } @@ -115,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; } } @@ -143,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', + ); } } @@ -151,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: @@ -171,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', + ); } } @@ -197,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); @@ -209,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(); @@ -228,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) { @@ -236,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; @@ -245,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; } @@ -264,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 = >[]; @@ -278,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; } @@ -325,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; } @@ -346,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(); @@ -365,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; @@ -389,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 ────────────────────────────────────────────────── @@ -409,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) { @@ -417,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) { @@ -429,7 +519,9 @@ class CloudStreamManager { } } } catch (e) { - debugPrint('[CloudStreamManager] Error fetching pluginList $listUrl: $e'); + debugPrint( + '[CloudStreamManager] Error fetching pluginList $listUrl: $e', + ); } } } @@ -463,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 && @@ -483,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); @@ -568,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; } @@ -591,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(); @@ -610,8 +714,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); @@ -676,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, @@ -689,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); @@ -701,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 { @@ -713,33 +831,63 @@ class CloudStreamManager { } } - Future uninstallExtension(CloudStreamSource source) async { - final extDir = await CloudStreamPaths.instance.extensionsDir; - - final candidates = { + Set _extensionFileNames(CloudStreamSource source) { + 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) { - final jarFile = File(p.join(extDir.path, '$baseName.jar')); - if (await jarFile.exists()) { - try { await jarFile.delete(); } catch (_) {} - } + 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()) { - try { await cs3File.delete(); } catch (_) {} - } + 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); + 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(); @@ -756,30 +904,21 @@ class CloudStreamManager { try { final extDir = await CloudStreamPaths.instance.extensionsDir; for (final source in sources) { - final candidates = { - source.name, - if (source.internalName != null) source.internalName!, - }; - - 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 (_) {} - } - } + final candidates = _extensionFileNames(source); + 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(); @@ -805,21 +944,28 @@ 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] ?? ''}'; 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) { - throw Exception('dex2jar compilation failed: ${process.stderr}\n${process.stdout}'); + throw Exception( + 'dex2jar compilation failed: ${process.stderr}\n${process.stdout}', + ); } } @@ -832,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) { @@ -871,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, + ), + ); } } } @@ -917,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; } @@ -934,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); @@ -989,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, @@ -1001,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; } @@ -1056,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)))) { @@ -1080,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; } @@ -1092,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'}") -> $targetEpisodeDataUrl'); + debugPrint( + '[CloudStreamManager] [$providerName] Matched S${sNum ?? season ?? 1}:E$epNum ("${ep['name'] ?? 'Episode $epNum'}") -> ${redactUrl(targetEpisodeDataUrl)}', + ); break; } } @@ -1137,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 "$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) { @@ -1175,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', + ); } } @@ -1193,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)!); } @@ -1216,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)!); } @@ -1269,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(); @@ -1284,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); @@ -1299,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; @@ -1322,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; } @@ -1342,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) { @@ -1363,26 +1615,38 @@ class CloudStreamManager { String? fallbackPoster, }) async { try { - debugPrint('[CloudStreamManager] fetchCloudStreamDetail: sourceId=$sourceId, url=$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) { @@ -1396,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