Compare commits

..

26 Commits

Author SHA1 Message Date
Gauvain
62c50dd728 ci(build): drop free-disk-space large-packages cleanup
The large-packages step runs `apt-get remove` on a glob list that includes
packages not present on the runner, so it always exits non-zero and the
action emits a `:⚠️:` annotation on every Android build (TV + Phone).
tool-cache (the largest reclaimable area, ~10-25 GB) is already left untouched
and builds pass with comfortable headroom, so the ~3-5 GB from large-packages
is not needed. Disabling it removes the only CI annotation.
2026-06-15 01:42:15 +02:00
Gauvain
149e3b1b17 fix(hooks): correct useMemo dependency arrays (#1626)
Some checks are pending
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Waiting to run
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Waiting to run
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Waiting to run
2026-06-15 01:01:38 +02:00
Gauvain
f00dad02ba ci(issue-form): auto-populate version dropdown from GitHub releases (#1641) 2026-06-15 00:24:44 +02:00
Gauvain
b7ec841118 refactor: remove dead code (#1625)
Some checks failed
🌐 Translation Sync / sync-translations (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-14 18:30:50 +02:00
Gauvain
03864b2a9a feat(home): refresh Continue Watching instantly via WebSocket (#1439) 2026-06-14 17:20:27 +02:00
Gauvain
96116e0451 feat(settings): show Actions run number for CI builds, hide store build number (#1711)
Some checks failed
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-11 11:08:07 +02:00
lance chant
938918fa06 fix: android tv issues (#1672)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-11 10:24:11 +02:00
renovate[bot]
a4b6f456f2 chore(deps): Update CI dependencies to v3.1.0 (#1715)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-11 09:10:19 +02:00
Gauvain
0a2dadffd2 feat(settings): graduated version tracking (build, branch, commit) (#1677)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2026-06-10 17:19:28 +02:00
Gauvain
6818ea380f fix(renovate): resolve maven lookups, unnest config, gate Expo SDK updates (#1708)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-10 11:22:49 +02:00
lance chant
7cf0a13317 fix: an issue with save account didn't show the modal (#1705)
Co-authored-by: Gauvain <contact@uruk.dev>
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-06-10 10:28:18 +02:00
renovate[bot]
168bf2e54e chore(deps): Update github/codeql-action action to v4.36.2 (#1687)
Some checks failed
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-06-09 23:25:26 +02:00
renovate[bot]
6f0230c2ca chore(deps): Update expo/expo-github-action digest to eab7a23 (#1685) 2026-06-09 22:37:14 +02:00
renovate[bot]
d12beee529 chore(deps): Update actions/checkout action to v6.0.3 (#1686) 2026-06-09 21:43:22 +02:00
renovate[bot]
02ffac167b chore(deps): Update dependency xcode to v26.5 (#1609)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
2026-06-09 15:58:35 +02:00
renovate[bot]
4eb734c99f chore(deps): Update expo monorepo to v1.19.9 (#1606) 2026-06-09 15:05:23 +02:00
Gauvain
b7bae0072f ci(i18n): detect unused & missing translation keys + clean key debt (#1636) 2026-06-09 11:46:42 +02:00
boolemancer
1685571406 fix(downloads): Use mediaSource.Id instead of item.Id in direct download URL (#1666)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Has been cancelled
Co-authored-by: lance chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <contact@uruk.dev>
2026-06-08 14:59:29 +02:00
Gauvain
36ed7539a2 ci(security): add Trivy filesystem scan to code scanning (#1644) 2026-06-08 14:05:23 +02:00
Gauvain
8f82ac481a chore: enforce LF line endings repo-wide via .gitattributes (#1643) 2026-06-08 13:33:54 +02:00
Gauvain
a242ff69fd chore(i18n): sentence-case and polish the en.json source (#1663) 2026-06-08 12:10:43 +02:00
Chris
b0c9dc114c Revise bug report template for clarity and specificity
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Updated issue report template to specify improvements for Streamyfin and enhanced placeholder text for clarity.
2026-06-05 17:54:07 +02:00
Chris
89190b5054 Revise issue report template for clarity and accuracy
Updated placeholders and descriptions for device and server information in issue report template.
2026-06-05 17:51:10 +02:00
Chris
394262af5a Update Streamyfin version options in issue template
Add new version options for Streamyfin in issue report template.
2026-06-05 17:37:35 +02:00
Gauvain
3dbe5bb64c ci(issues): detect likely-duplicate issues on open (#1645)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Has been cancelled
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-06-05 14:21:12 +02:00
Gauvain
801ab275ab fix(icon): downsize the icon and make them appropriate (#1660) 2026-06-05 11:29:38 +02:00
166 changed files with 2735 additions and 6619 deletions

View File

@@ -45,4 +45,4 @@ This file previously contained facts about the codebase learned from past sessio
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_ - **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_ - **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_

29
.gitattributes vendored
View File

@@ -1 +1,28 @@
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text # Normalise line endings to LF for everyone. Files are stored as LF in git and
# checked out as LF on every OS, so Windows clones stop producing CRLF churn
# (no more "LF will be replaced by CRLF" warnings) regardless of core.autocrlf.
* text=auto eol=lf
# Windows-only scripts must stay CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
# Binary assets — never touched / never normalised
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.icns binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.mp3 binary
*.mp4 binary
*.mov binary
*.pdf binary
*.keystore binary
*.jks binary
*.p12 binary

View File

@@ -1,5 +1,5 @@
name: "🐛 Bug Report" name: "🐛 Bug Report"
description: Create a report to help us improve description: Create a report to help Streamyfin improve
title: "[Bug]: " title: "[Bug]: "
labels: labels:
- "🐛 bug" - "🐛 bug"
@@ -36,7 +36,7 @@ body:
attributes: attributes:
label: What happened? label: What happened?
description: A clear and concise description of what the bug is. description: A clear and concise description of what the bug is.
placeholder: Describe what happened in detail. placeholder: Describe what happened in detail, the more precise the better.
validations: validations:
required: true required: true
@@ -67,7 +67,7 @@ body:
attributes: attributes:
label: Which device and operating system are you using? label: Which device and operating system are you using?
description: Please provide your device model and OS version description: Please provide your device model and OS version
placeholder: e.g. iPhone 15 Pro, iOS 18.1.1 or Samsung Galaxy S24, Android 14 placeholder: e.g. iPhone 17 Pro / iOS 26.5.1, Samsung Galaxy S25 / Android 16, Apple TV / tvOS 26.5
validations: validations:
required: true required: true
@@ -75,11 +75,14 @@ body:
id: version id: version
attributes: attributes:
label: Streamyfin Version label: Streamyfin Version
description: What version of Streamyfin are you running? description: What version of Streamyfin are you running? On a TestFlight or development build, choose "TestFlight/Development build" and include the exact version string shown in the app's Settings.
options: options:
- 0.54.1
- 0.51.0
- 0.47.1 - 0.47.1
- 0.30.2 - 0.30.2
- older - 0.28.0
- Older
- TestFlight/Development build - TestFlight/Development build
validations: validations:
required: true required: true
@@ -90,9 +93,9 @@ body:
label: Jellyfin Server Information label: Jellyfin Server Information
description: Please provide details about your Jellyfin server description: Please provide details about your Jellyfin server
placeholder: | placeholder: |
- Jellyfin Server Version: e.g. 10.10.7 - Jellyfin Server Version: e.g. 10.11.10
- Server OS: e.g. Ubuntu 22.04, Windows 11, Docker - Server OS: e.g. Ubuntu 26.04, Windows 11, Docker, Proxmox
- Connection: e.g. Local network, Remote via domain, VPN - Connection: e.g. Local network, remote via domain, VPN
- type: textarea - type: textarea
id: screenshots id: screenshots
@@ -104,7 +107,7 @@ body:
id: logs id: logs
attributes: attributes:
label: Relevant logs (if available) label: Relevant logs (if available)
description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URLs or usernames.** description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URL, API keys or usernames.**
render: shell render: shell
- type: textarea - type: textarea

54
.github/renovate.json vendored
View File

@@ -44,22 +44,42 @@
] ]
} }
}, },
"lockFileMaintenance": { "vulnerabilityAlerts": {
"vulnerabilityAlerts": { "enabled": true,
"enabled": true, "addLabels": ["security", "vulnerability"],
"addLabels": ["security", "vulnerability"], "assigneesFromCodeOwners": true,
"assigneesFromCodeOwners": true, "commitMessageSuffix": " [SECURITY]"
"commitMessageSuffix": " [SECURITY]" },
"packageRules": [
{
"description": "Expo SDK coherence: expo, react, react-native and Expo-managed modules are pinned by the Expo SDK and must move together (via `expo install --fix`), so do not raise individual update PRs — group them and require manual approval from the Dependency Dashboard",
"matchPackageNames": [
"expo",
"react",
"react-dom",
"react-native",
"react-native-web",
"expo-*",
"@expo/*"
],
"groupName": "Expo SDK",
"dependencyDashboardApproval": true
}, },
"packageRules": [ {
{ "description": "Group minor and patch GitHub Action updates into a single PR",
"description": "Group minor and patch GitHub Action updates into a single PR", "matchManagers": ["github-actions"],
"matchManagers": ["github-actions"], "groupName": "CI dependencies",
"groupName": "CI dependencies", "groupSlug": "ci-deps",
"groupSlug": "ci-deps", "matchUpdateTypes": ["minor", "patch", "digest", "pin"],
"matchUpdateTypes": ["minor", "patch", "digest", "pin"], "automerge": true
"automerge": true },
} {
] "description": "androidx and other Google-hosted Maven packages resolve from Google's Maven repository (not Maven Central)",
} "matchDatasources": ["maven"],
"registryUrls": [
"https://dl.google.com/dl/android/maven2/",
"https://repo.maven.apache.org/maven2/"
]
}
]
} }

View File

@@ -11,6 +11,15 @@ on:
push: push:
branches: [develop, master] branches: [develop, master]
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
# run (artifacts + logs) without needing Expo access.
env:
EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
EXPO_PUBLIC_GIT_RUN_NUMBER: ${{ github.run_number }}
jobs: jobs:
build-android-phone: build-android-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]')) if: (!contains(github.event.head_commit.message, '[skip ci]'))
@@ -28,12 +37,12 @@ jobs:
android: false android: false
dotnet: true dotnet: true
haskell: true haskell: true
large-packages: true large-packages: false
docker-images: true docker-images: true
swap-storage: false swap-storage: false
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -111,12 +120,12 @@ jobs:
android: false android: false
dotnet: true dotnet: true
haskell: true haskell: true
large-packages: true large-packages: false
docker-images: true docker-images: true
swap-storage: false swap-storage: false
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -187,7 +196,7 @@ jobs:
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -219,10 +228,10 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with: with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose # renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4" xcode-version: "26.5"
- name: 🏗️ Setup EAS - name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with: with:
eas-version: latest eas-version: latest
token: ${{ secrets.EXPO_TOKEN }} token: ${{ secrets.EXPO_TOKEN }}
@@ -231,7 +240,9 @@ jobs:
- name: 🚀 Build iOS app - name: 🚀 Build iOS app
env: env:
EXPO_TV: 0 EXPO_TV: 0
run: eas build -p ios --local --non-interactive # `ci` profile (extends production, autoIncrement off): keeps CI builds out of
# the production version tier and stops them inflating the store build counter.
run: eas build -p ios --local --non-interactive --profile ci
- name: 📅 Set date tag - name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -252,7 +263,7 @@ jobs:
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -284,7 +295,7 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with: with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose # renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4" xcode-version: "26.5"
- name: 🚀 Build iOS app - name: 🚀 Build iOS app
env: env:
@@ -312,7 +323,7 @@ jobs:
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -344,10 +355,10 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with: with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose # renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4" xcode-version: "26.5"
- name: 🏗️ Setup EAS - name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with: with:
eas-version: latest eas-version: latest
token: ${{ secrets.EXPO_TOKEN }} token: ${{ secrets.EXPO_TOKEN }}
@@ -356,7 +367,7 @@ jobs:
- name: 🚀 Build iOS app - name: 🚀 Build iOS app
env: env:
EXPO_TV: 1 EXPO_TV: 1
run: eas build -p ios --local --non-interactive run: eas build -p ios --local --non-interactive --profile ci_tv
- name: 📅 Set date tag - name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -380,7 +391,7 @@ jobs:
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -412,7 +423,7 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with: with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose # renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4" xcode-version: "26.5"
- name: 🚀 Build iOS app - name: 🚀 Build iOS app
env: env:

View File

@@ -19,7 +19,7 @@ jobs:
steps: steps:
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false show-progress: false

View File

@@ -24,16 +24,16 @@ jobs:
steps: steps:
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: 🏁 Initialize CodeQL - name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild - name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
- name: 🧪 Perform CodeQL Analysis - name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2

View File

@@ -17,7 +17,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: 🚩 Apply merge conflict label - name: 🚩 Apply merge conflict label
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3 uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
with: with:
dirtyLabel: '⚔️ merge-conflict' dirtyLabel: '⚔️ merge-conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.' commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'

View File

@@ -1,51 +1,51 @@
name: 🌐 Translation Sync name: 🌐 Translation Sync
on: on:
push: push:
branches: [develop] branches: [develop]
paths: paths:
- "translations/**" - "translations/**"
- "crowdin.yml" - "crowdin.yml"
- "i18n.ts" - "i18n.ts"
- ".github/workflows/crowdin.yml" - ".github/workflows/crowdin.yml"
# Run weekly to pull new translations # Run weekly to pull new translations
schedule: schedule:
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC - cron: "0 2 * * 1" # Every Monday at 2 AM UTC
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: write contents: write
pull-requests: write pull-requests: write
jobs: jobs:
sync-translations: sync-translations:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: 📥 Checkout Repository - name: 📥 Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 0 fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin - name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2 uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
with: with:
upload_sources: true upload_sources: true
upload_translations: true upload_translations: true
download_translations: true download_translations: true
localization_branch_name: I10n_crowdin_translations localization_branch_name: I10n_crowdin_translations
create_pull_request: true create_pull_request: true
pull_request_title: "feat: New Crowdin Translations" pull_request_title: "feat: New Crowdin Translations"
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)" pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
pull_request_base_branch_name: "develop" pull_request_base_branch_name: "develop"
pull_request_labels: "🌐 translation" pull_request_labels: "🌐 translation"
# Quality control options # Quality control options
skip_untranslated_strings: false skip_untranslated_strings: false
skip_untranslated_files: false skip_untranslated_files: false
export_only_approved: false export_only_approved: false
# Commit customization # Commit customization
commit_message: "feat(i18n): update translations from Crowdin" commit_message: "feat(i18n): update translations from Crowdin"
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

38
.github/workflows/detect-duplicate.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: 🔁 Detect Duplicate Issues
on:
issues:
types: [opened]
permissions:
contents: read
concurrency:
group: detect-duplicate-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
detect:
name: 🔍 Find similar issues
if: github.actor != 'github-actions[bot]'
runs-on: ubuntu-24.04
permissions:
issues: write
contents: read
steps:
- name: 📥 Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 🔍 Detect duplicate issues
run: bun scripts/detect-duplicate-issue.mjs
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}

View File

@@ -51,7 +51,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
@@ -69,7 +69,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: 🛒 Checkout repository - name: 🛒 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive submodules: recursive
@@ -97,10 +97,11 @@ jobs:
- "check" - "check"
- "format" - "format"
- "typecheck" - "typecheck"
- "i18n:check"
steps: steps:
- name: "📥 Checkout PR code" - name: "📥 Checkout PR code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive submodules: recursive

View File

@@ -63,7 +63,7 @@ jobs:
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: recursive submodules: recursive
@@ -88,7 +88,7 @@ jobs:
bun run submodule-reload bun run submodule-reload
- name: 🏗️ Setup EAS - name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with: with:
eas-version: latest eas-version: latest
token: ${{ secrets.EXPO_TOKEN }} token: ${{ secrets.EXPO_TOKEN }}
@@ -182,7 +182,7 @@ jobs:
actions: read # required for `gh run download` to list/fetch this run's artifacts actions: read # required for `gh run download` to list/fetch this run's artifacts
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
fetch-depth: 0 fetch-depth: 0
show-progress: false show-progress: false

60
.github/workflows/trivy-scan.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: 🛡️ Trivy Security Scan
# Filesystem scan (Streamyfin ships no container image): finds vulnerable dependencies,
# leaked secrets and misconfigurations, and reports them to GitHub code scanning.
# Runs post-merge + weekly (not on PRs — dependency-review already gates PRs, and SARIF
# upload needs a write token that fork PRs don't get).
on:
push:
branches: [develop, master]
schedule:
- cron: "50 7 * * 5" # Weekly, Friday 07:50 UTC
workflow_dispatch:
permissions:
contents: read
concurrency:
group: trivy-${{ github.ref }}
cancel-in-progress: true
jobs:
trivy:
name: 🔎 Filesystem scan
runs-on: ubuntu-24.04
permissions:
contents: read
security-events: write # upload SARIF to code scanning
steps:
- name: 📥 Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# Rotate the DB cache weekly (matches the scheduled scan): cache hits within the week
# instead of a fresh immutable entry per run, still refreshing the DB every week.
- name: 🗓️ Compute weekly Trivy cache key
id: trivy-cache-key
run: echo "value=trivy-db-${{ runner.os }}-$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
- name: 💾 Cache Trivy vulnerability DB
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/trivy
key: ${{ steps.trivy-cache-key.outputs.value }}
restore-keys: trivy-db-${{ runner.os }}-
- name: 🔎 Run Trivy filesystem scan
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: .
scanners: vuln,secret,misconfig
ignore-unfixed: true
severity: CRITICAL,HIGH
format: sarif
output: trivy-results.sarif
- name: 📤 Upload results to code scanning
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: trivy-results.sarif
category: trivy-fs

View File

@@ -1,67 +1,102 @@
name: 🐛 Update Bug Report Template name: 🐛 Update Issue Form Versions
on: on:
release: release:
types: [published] # Run on every published release on any branch # Only full releases populate the dropdown (no drafts/prereleases).
types: [released]
schedule:
- cron: "0 3 * * 1" # Weekly safety net (Mondays 03:00 UTC) in case a release event was missed
workflow_dispatch:
# Fixed group so a release event and the weekly cron can't race on the same
# ci/update-issue-form branch — runs queue instead of force-pushing over each other.
concurrency: concurrency:
group: update-issue-form-${{ github.event.release.tag_name || github.run_id }} group: update-issue-form
cancel-in-progress: true cancel-in-progress: false
permissions:
contents: read
jobs: jobs:
update-bug-report: update-issue-form:
name: 🔢 Populate version dropdown
runs-on: ubuntu-24.04
permissions: permissions:
contents: write contents: write
pull-requests: write pull-requests: write
issues: write
runs-on: ubuntu-24.04
steps: steps:
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: '24.x' # On `release` events GITHUB_SHA is the tagged commit — without this the
cache: 'npm' # script would regenerate the form from the tag's (stale) copy and the bot
# PR would revert any form edits made on develop since that release.
ref: develop
- name: 🔍 Extract minor version from app.json - name: 🍞 Setup Bun
id: minor uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
with: with:
result-encoding: string bun-version: latest
script: |
const fs = require('fs-extra');
const semver = require('semver');
const content = fs.readJsonSync('./app.json');
const version = content.expo.version;
const minorVersion = semver.minor(version);
return minorVersion.toString();
- name: 📝 Update bug report version - name: 🔢 Populate version dropdown from GitHub releases
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5 id: populate
with: run: bun scripts/update-issue-form.mjs
semver: '^0.${{ steps.minor.outputs.result }}.0' env:
dry_run: no-push GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
- name: ⚙️ Update bug report node version dropdown - name: 📬 Create pull request
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5 id: cpr
with:
dropdown: _node_version
package: node
semver: '>=24.0.0'
dry_run: no-push
- name: 📬 Commit and create pull request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with: with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml add-paths: .github/ISSUE_TEMPLATE/issue_report.yml
branch: ci-update-bug-report branch: ci/update-issue-form
base: develop base: develop
delete-branch: true delete-branch: true
labels: ⚙️ ci, 🤖 github-actions labels: ⚙️ ci, 🤖 github-actions
title: 'chore(): Update bug report template to match release version' commit-message: "chore: update issue form version dropdown"
title: "chore: update issue form version dropdown"
# Follows .github/pull_request_template.md so the bot PR isn't flagged by PR validation.
body: | body: |
Automated update to `.github/ISSUE_TEMPLATE/bug_report.yml` # 📦 Pull Request
Triggered by workflow run [${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
## 📝 Description
Automated update of the **Streamyfin Version** dropdown in `.github/ISSUE_TEMPLATE/issue_report.yml`, populated from the latest published GitHub releases by `scripts/update-issue-form.mjs`.
**Version dropdown now lists:** ${{ steps.populate.outputs.versions }}
Triggered by `${{ github.event_name }}`${{ github.event.release.tag_name && format(' — release {0}', github.event.release.tag_name) || '' }} · [run ${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}).
## 🏷️ Ticket / Issue
N/A — automated maintenance.
### 🖼️ Screenshots / GIFs (if UI)
N/A — issue-template metadata only, no app UI.
## ✅ Checklist
- [x] Ive read the [contribution guidelines](CONTRIBUTING.md)
- [x] Verified that changes behave as expected for all platforms
- [x] Code passes lint/formatting and type checks (`tsc`/`biome`)
- [x] No secrets, hardcoded credentials, or private config files are included
- [x] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
## 🔍 Testing Instructions
N/A — generated by CI from published releases; review the dropdown diff in `issue_report.yml`.
- name: 🔀 Enable auto-merge
if: steps.cpr.outputs.pull-request-operation == 'created'
env:
GH_TOKEN: ${{ github.token }}
# Known limitation: PRs created with GITHUB_TOKEN don't trigger CI workflows
# (GitHub anti-recursion), so the required checks stay "Expected" until a
# maintainer kicks them (close/reopen the PR, or push an empty commit).
# Auto-merge is still worth enabling: once checks run and reviews land,
# the PR merges itself.
run: |
gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}" \
|| echo "::warning::Could not enable auto-merge — enable 'Allow auto-merge' in repo settings (and branch protection); merge the PR manually for now."

11
.gitignore vendored
View File

@@ -1,6 +1,5 @@
# Dependencies and Package Managers # Dependencies and Package Managers
node_modules/ node_modules/
bun.lock
bun.lockb bun.lockb
package-lock.json package-lock.json
@@ -21,10 +20,8 @@ web-build/
# Gradle caches (top-level + per-module native projects) # Gradle caches (top-level + per-module native projects)
**/.gradle/ **/.gradle/
# Module-specific Builds # Native module build outputs (any module)
modules/mpv-player/android/build modules/*/android/build/
modules/player/android
modules/hls-downloader/android/build
# Generated Applications # Generated Applications
Streamyfin.app Streamyfin.app
@@ -69,10 +66,6 @@ certs/
# Version and Backup Files # Version and Backup Files
/version-backup-* /version-backup-*
/modules/sf-player/android/build
/modules/music-controls/android/build
modules/background-downloader/android/build/*
/modules/mpv-player/android/build
# ios:unsigned-build Artifacts # ios:unsigned-build Artifacts
build/ build/

View File

@@ -143,14 +143,6 @@ interface ModalOptions {
} }
``` ```
## Examples
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
- Simple content modal
- Modal with custom snap points
- Complex component in modal
- Success/error modals triggered from functions
## Default Styling ## Default Styling
The modal uses these default styles (can be overridden via options): The modal uses these default styles (can be overridden via options):

View File

@@ -1,3 +1,47 @@
const { execFileSync } = require("node:child_process");
// Build metadata, injected into `extra.build` and read at runtime via
// expo-constants (see utils/version.ts). Sources in priority order:
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
const git = (args) => {
try {
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
.toString()
.trim();
} catch {
return null;
}
};
const buildMeta = {
commit:
(
process.env.EAS_BUILD_GIT_COMMIT_HASH ||
process.env.GITHUB_SHA ||
process.env.EXPO_PUBLIC_GIT_COMMIT ||
git(["rev-parse", "HEAD"]) ||
""
).slice(0, 7) || null,
branch:
process.env.EAS_BUILD_GIT_BRANCH ||
process.env.GITHUB_HEAD_REF ||
process.env.GITHUB_REF_NAME ||
process.env.EXPO_PUBLIC_GIT_BRANCH ||
git(["rev-parse", "--abbrev-ref", "HEAD"]) ||
null,
profile:
process.env.EAS_BUILD_PROFILE ||
process.env.EXPO_PUBLIC_BUILD_PROFILE ||
null,
// GitHub Actions run number (#2098) — lets anyone map a sideloaded CI build back
// to its Actions run (artifacts + logs) without Expo access. Null outside CI.
runNumber:
process.env.GITHUB_RUN_NUMBER ||
process.env.EXPO_PUBLIC_GIT_RUN_NUMBER ||
null,
builtAt: new Date().toISOString(),
};
module.exports = ({ config }) => { module.exports = ({ config }) => {
if (process.env.EXPO_TV !== "1") { if (process.env.EXPO_TV !== "1") {
config.plugins.push("expo-background-task"); config.plugins.push("expo-background-task");
@@ -22,6 +66,8 @@ module.exports = ({ config }) => {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON; androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
} }
config.extra = { ...config.extra, build: buildMeta };
return { return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }), ...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config, ...config,

View File

@@ -161,9 +161,7 @@ export default function FavoritesSeeAllScreen() {
/> />
{!itemType ? ( {!itemType ? (
<View className='flex-1 items-center justify-center px-6'> <View className='flex-1 items-center justify-center px-6'>
<Text className='text-neutral-500'> <Text className='text-neutral-500'>{t("favorites.noData")}</Text>
{t("favorites.noData", { defaultValue: "No items found." })}
</Text>
</View> </View>
) : isLoading ? ( ) : isLoading ? (
<View className='justify-center items-center h-full'> <View className='justify-center items-center h-full'>
@@ -194,7 +192,7 @@ export default function FavoritesSeeAllScreen() {
ListEmptyComponent={ ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full py-12'> <View className='flex flex-col items-center justify-center h-full py-12'>
<Text className='font-bold text-xl text-neutral-500'> <Text className='font-bold text-xl text-neutral-500'>
{t("home.no_items", { defaultValue: "No items" })} {t("home.no_items")}
</Text> </Text>
</View> </View>
} }

View File

@@ -7,9 +7,6 @@ import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageSta
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast"); const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
const SyncPlayButtonComponent = Platform.isTV
? null
: require("@/components/syncplay/SyncPlayButton").SyncPlayButton;
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { HeaderBackButton } from "@/components/common/HeaderBackButton"; import { HeaderBackButton } from "@/components/common/HeaderBackButton";
@@ -36,7 +33,6 @@ export default function IndexLayout() {
{!Platform.isTV && ( {!Platform.isTV && (
<> <>
<Chromecast.Chromecast background='transparent' /> <Chromecast.Chromecast background='transparent' />
{SyncPlayButtonComponent && <SyncPlayButtonComponent />}
{user?.Policy?.IsAdministrator && <SessionsButton />} {user?.Policy?.IsAdministrator && <SessionsButton />}
<SettingsButton /> <SettingsButton />
</> </>

View File

@@ -137,12 +137,12 @@ export default function DownloadsPage() {
deleteFileByType("Episode") deleteFileByType("Episode")
.then(() => .then(() =>
toast.success( toast.success(
t("home.downloads.toasts.deleted_all_tvseries_successfully"), t("home.downloads.toasts.deleted_all_series_successfully"),
), ),
) )
.catch((reason) => { .catch((reason) => {
writeToLog("ERROR", reason); writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries")); toast.error(t("home.downloads.toasts.failed_to_delete_all_series"));
}); });
const deleteOtherMedia = () => const deleteOtherMedia = () =>
Promise.all( Promise.all(
@@ -207,7 +207,7 @@ export default function DownloadsPage() {
<View className='mb-4'> <View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'> <View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'> <Text className='text-lg font-bold'>
{t("home.downloads.tvseries")} {t("home.downloads.series")}
</Text> </Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'> <View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'> <Text className='text-xs font-bold'>
@@ -288,7 +288,7 @@ export default function DownloadsPage() {
{t("home.downloads.delete_all_movies_button")} {t("home.downloads.delete_all_movies_button")}
</Button> </Button>
<Button color='purple' onPress={deleteShows}> <Button color='purple' onPress={deleteShows}>
{t("home.downloads.delete_all_tvseries_button")} {t("home.downloads.delete_all_series_button")}
</Button> </Button>
{otherMedia.length > 0 && ( {otherMedia.length > 0 && (
<Button color='purple' onPress={deleteOtherMedia}> <Button color='purple' onPress={deleteOtherMedia}>

View File

@@ -179,18 +179,15 @@ export default function SettingsTV() {
// Handle clearing all cache in the entire app // Handle clearing all cache in the entire app
const handleClearCache = async () => { const handleClearCache = async () => {
Alert.alert( Alert.alert(
t("home.settings.storage.clear_all_cache_confirm", "Clear All Cache?"), t("home.settings.storage.clear_all_cache_confirm"),
t( t("home.settings.storage.clear_all_cache_confirm_desc"),
"home.settings.storage.clear_all_cache_confirm_desc",
"Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
),
[ [
{ {
text: t("common.cancel", "Cancel"), text: t("common.cancel"),
style: "cancel", style: "cancel",
}, },
{ {
text: t("common.ok", "OK"), text: t("common.ok"),
onPress: async () => { onPress: async () => {
try { try {
// 1. Clear React Query Cache (memory & MMKV) // 1. Clear React Query Cache (memory & MMKV)
@@ -243,11 +240,8 @@ export default function SettingsTV() {
} catch (error) { } catch (error) {
console.error("Failed to clear cache:", error); console.error("Failed to clear cache:", error);
Alert.alert( Alert.alert(
t("home.settings.toasts.error_deleting_files", "Error"), t("home.settings.toasts.error_deleting_files"),
t( t("home.settings.storage.clear_all_cache_error_desc"),
"home.settings.storage.clear_all_cache_error_desc",
"An error occurred while clearing the cache.",
),
); );
} }
}, },

View File

@@ -102,8 +102,8 @@ export default function TabLayout() {
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab, !settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/list.png") ? (_e) => require("@/assets/icons/list.star.png")
: (_e) => ({ sfSymbol: "list.bullet.rectangle" }), : (_e) => ({ sfSymbol: "list.star" }),
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
@@ -112,7 +112,7 @@ export default function TabLayout() {
title: t("tabs.library"), title: t("tabs.library"),
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/server.rack.png") ? (_e) => require("@/assets/icons/rectangle.stack.fill.png")
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }), : (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
}} }}
/> />
@@ -123,8 +123,8 @@ export default function TabLayout() {
tabBarItemHidden: !settings?.showCustomMenuLinks, tabBarItemHidden: !settings?.showCustomMenuLinks,
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/list.png") ? (_e) => require("@/assets/icons/link.png")
: (_e) => ({ sfSymbol: "list.dash.fill" }), : (_e) => ({ sfSymbol: "link" }),
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
@@ -134,7 +134,7 @@ export default function TabLayout() {
tabBarItemHidden: !Platform.isTV, tabBarItemHidden: !Platform.isTV,
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/gear.png") //Should maybe use other libraries to have it uniform ? (_e) => require("@/assets/icons/gearshape.fill.png")
: (_e) => ({ sfSymbol: "gearshape.fill" }), : (_e) => ({ sfSymbol: "gearshape.fill" }),
}} }}
/> />

View File

@@ -31,11 +31,9 @@ import {
} from "@/components/video-player/controls/utils/playback-speed-settings"; } from "@/components/video-player/controls/utils/playback-speed-settings";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed"; import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
import { import {
@@ -51,10 +49,10 @@ import { DownloadedItem } from "@/providers/Downloads/types";
import { useInactivity } from "@/providers/InactivityProvider"; import { useInactivity } from "@/providers/InactivityProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSyncPlay } from "@/providers/SyncPlay";
import type { PlayerControls } from "@/providers/SyncPlay/types";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles"; import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { import {
@@ -79,11 +77,6 @@ export default function DirectPlayerPage() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
const [isPipMode, setIsPipMode] = useState(false); const [isPipMode, setIsPipMode] = useState(false);
// Keep the global WebSocket open while in PiP so SyncPlay commands
// (and any other server pushes) keep flowing while iOS treats the
// app as backgrounded. See `WebSocketProvider.acquireKeepAlive`.
useKeepWebSocketAlive(isPipMode);
const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">( const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
"default", "default",
); );
@@ -135,7 +128,6 @@ export default function DirectPlayerPage() {
bitrateValue: bitrateValueStr, bitrateValue: bitrateValueStr,
offline: offlineStr, offline: offlineStr,
playbackPosition: playbackPositionFromUrl, playbackPosition: playbackPositionFromUrl,
syncPlay: syncPlayStr,
} = useLocalSearchParams<{ } = useLocalSearchParams<{
itemId: string; itemId: string;
audioIndex: string; audioIndex: string;
@@ -145,23 +137,9 @@ export default function DirectPlayerPage() {
offline: string; offline: string;
/** Playback position in ticks. */ /** Playback position in ticks. */
playbackPosition?: string; playbackPosition?: string;
/** Whether playback was initiated by SyncPlay */
syncPlay?: string;
}>(); }>();
// When opened via SyncPlay, don't auto-play - let SyncPlay commands control playback
const openedViaSyncPlay = syncPlayStr === "true";
const { lockOrientation, unlockOrientation } = useOrientation(); const { lockOrientation, unlockOrientation } = useOrientation();
// SyncPlay integration
const syncPlay = useSyncPlay();
const {
isEnabled: isSyncPlayEnabled,
controller: syncPlayController,
setPlayerControls,
notifyBuffering,
} = syncPlay;
const offline = offlineStr === "true"; const offline = offlineStr === "true";
// Audio index: use URL param if provided, otherwise use stored index for offline playback // Audio index: use URL param if provided, otherwise use stored index for offline playback
@@ -437,72 +415,8 @@ export default function DirectPlayerPage() {
reportPlaybackStart(); reportPlaybackStart();
}, [stream, api, offline]); }, [stream, api, offline]);
// SyncPlay: Connect player controls when video is ready
useEffect(() => {
if (!isVideoLoaded || !videoRef.current || offline) {
setPlayerControls(null);
return;
}
const controls: PlayerControls = {
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
seekTo: (positionMs: number) => {
const positionSec = positionMs / 1000;
console.log(
`PlayerControls.seekTo: ${positionMs}ms = ${positionSec}s, videoRef exists: ${!!videoRef.current}`,
);
videoRef.current?.seekTo(positionSec);
},
setSpeed: (speed: number) => videoRef.current?.setSpeed?.(speed),
getSpeed: () => currentPlaybackSpeed,
getCurrentPosition: () => progress.get(),
isPlaying: () => isPlaying,
isBuffering: () => isBuffering,
};
setPlayerControls(controls);
return () => {
setPlayerControls(null);
};
}, [
isVideoLoaded,
offline,
isPlaying,
isBuffering,
currentPlaybackSpeed,
progress,
setPlayerControls,
]);
// SyncPlay: Report buffering/ready state to server.
//
// CRITICAL: We must report `buffering` to the server *during* initial
// load (before `isVideoLoaded`), otherwise the server treats us as ready
// and proceeds without waiting for us. jellyfin-web reports this for
// free via the HTML5 video element's `waiting` event; for us, the
// initial load itself is the buffering window.
useEffect(() => {
if (!isSyncPlayEnabled) {
return;
}
const isLocallyReady = isVideoLoaded && !isBuffering;
// notifyBuffering routes through the debouncer in PlaybackCore so
// re-renders during a stall don't spam the server.
notifyBuffering(!isLocallyReady);
}, [isSyncPlayEnabled, isVideoLoaded, isBuffering, notifyBuffering]);
const togglePlay = async () => { const togglePlay = async () => {
lightHapticFeedback(); lightHapticFeedback();
// Route through SyncPlay when active
if (isSyncPlayEnabled && syncPlayController) {
syncPlayController.playPause();
return;
}
setIsPlaying(!isPlaying); setIsPlaying(!isPlaying);
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();
@@ -731,12 +645,10 @@ export default function DirectPlayerPage() {
const startPos = ticksToSeconds(startTicks); const startPos = ticksToSeconds(startTicks);
// Build source config - headers only needed for online streaming // Build source config - headers only needed for online streaming
// When opened via SyncPlay, don't auto-play - SyncPlay commands control playback
const shouldAutoplay = !openedViaSyncPlay;
const source: MpvVideoSource = { const source: MpvVideoSource = {
url: stream.url, url: stream.url,
startPosition: startPos, startPosition: startPos,
autoplay: shouldAutoplay, autoplay: true,
initialSubtitleId, initialSubtitleId,
initialAudioId, initialAudioId,
// Pass cache/buffer settings from user preferences // Pass cache/buffer settings from user preferences
@@ -931,41 +843,6 @@ export default function DirectPlayerPage() {
[], [],
); );
// PiP playback controls. When SyncPlay is active, the native side
// is told to *delegate* these via `syncPlayDelegated`, so the OS
// play/pause/skip buttons emit these events instead of poking MPV
// directly. We route them through the SyncPlay controller so the
// server broadcasts a command to every group member (including us).
const _onPipPlayRequest = useCallback(() => {
if (isSyncPlayEnabled && syncPlayController) {
console.log("SyncPlay: PiP play → controller.playPause()");
syncPlayController.playPause();
}
}, [isSyncPlayEnabled, syncPlayController]);
const _onPipPauseRequest = useCallback(() => {
if (isSyncPlayEnabled && syncPlayController) {
console.log("SyncPlay: PiP pause → controller.playPause()");
syncPlayController.playPause();
}
}, [isSyncPlayEnabled, syncPlayController]);
const _onPipSkipRequest = useCallback(
(e: {
nativeEvent: { targetSeconds: number; intervalSeconds: number };
}) => {
if (!isSyncPlayEnabled || !syncPlayController) return;
const { targetSeconds } = e.nativeEvent;
// SyncPlay seek takes ticks (1 s = 10_000_000 ticks).
const ticks = Math.max(0, Math.round(targetSeconds * 10_000_000));
console.log(
`SyncPlay: PiP skip → controller.seek(${targetSeconds}s = ${ticks} ticks)`,
);
syncPlayController.seek(ticks);
},
[isSyncPlayEnabled, syncPlayController],
);
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting // Add useEffect to handle mounting
@@ -990,21 +867,10 @@ export default function DirectPlayerPage() {
videoRef.current?.pause?.(); videoRef.current?.pause?.();
}, []); }, []);
const seek = useCallback( const seek = useCallback((position: number) => {
(position: number) => { // MPV expects seconds, convert from ms
// Route through SyncPlay when active. `position` is in ms; the videoRef.current?.seekTo?.(position / 1000);
// controller takes ticks (1 ms = 10000 ticks). }, []);
if (isSyncPlayEnabled && syncPlayController) {
console.log("SyncPlay: seek requested via SyncPlay", position);
syncPlayController.seek(Math.round(position * 10000));
return;
}
// MPV expects seconds, convert from ms
videoRef.current?.seekTo?.(position / 1000);
},
[isSyncPlayEnabled, syncPlayController],
);
// TV audio track change handler // TV audio track change handler
const handleAudioIndexChange = useCallback( const handleAudioIndexChange = useCallback(
@@ -1144,6 +1010,44 @@ export default function DirectPlayerPage() {
} }
}, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]); }, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
// TV: Navigate to previous item
const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return;
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(previousItem, settings, {
indexes: {
// Use the live selection, not the stale URL params (see goToNextItem).
subtitleIndex: currentSubtitleIndex,
audioIndex: currentAudioIndex,
},
source: stream?.mediaSource ?? undefined,
});
const queryParams = new URLSearchParams({
itemId: previousItem.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition:
previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
router.replace(`player/direct-player?${queryParams}` as any);
}, [
previousItem,
settings,
currentSubtitleIndex,
currentAudioIndex,
stream?.mediaSource,
bitrateValue,
router,
]);
// TV: Add subtitle file to player (for client-side downloaded subtitles) // TV: Add subtitle file to player (for client-side downloaded subtitles)
const addSubtitleFile = useCallback(async (path: string) => { const addSubtitleFile = useCallback(async (path: string) => {
await videoRef.current?.addSubtitleFile?.(path, true); await videoRef.current?.addSubtitleFile?.(path, true);
@@ -1173,25 +1077,45 @@ export default function DirectPlayerPage() {
return []; return [];
}, [isMounted]); }, [isMounted]);
/* // TV: Navigate to next item
* Item-level navigation (next / previous). Wraps SyncPlay dispatch, const goToNextItem = useCallback(() => {
* platform-appropriate local navigation (replace on TV), and offline if (!nextItem || !settings || isPlaybackStopped) return;
* param injection in a single hook so the in-player buttons and any
* future entry points (autoplay overlay, episode picker, etc.) share const {
* one implementation. mediaSource: newMediaSource,
*/ audioIndex: defaultAudioIndex,
const { subtitleIndex: defaultSubtitleIndex,
goToNextItem: dispatchNextItem, } = getDefaultPlaySettings(nextItem, settings, {
goToPreviousItem: dispatchPreviousItem, indexes: {
} = usePlayerItemNavigation({ // Use the live selection (updated when the user changes tracks
// mid-playback), not the stale URL params the episode started with.
subtitleIndex: currentSubtitleIndex,
audioIndex: currentAudioIndex,
},
source: stream?.mediaSource ?? undefined,
});
const queryParams = new URLSearchParams({
itemId: nextItem.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition:
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
router.replace(`player/direct-player?${queryParams}` as any);
}, [
nextItem, nextItem,
previousItem, settings,
mediaSource: stream?.mediaSource,
currentAudioIndex,
currentSubtitleIndex, currentSubtitleIndex,
currentAudioIndex,
stream?.mediaSource,
bitrateValue, bitrateValue,
isDisabled: isPlaybackStopped, router,
}); isPlaybackStopped,
]);
// Apply subtitle settings when video loads // Apply subtitle settings when video loads
useEffect(() => { useEffect(() => {
@@ -1338,10 +1262,6 @@ export default function DirectPlayerPage() {
onProgress={onProgress} onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged} onPlaybackStateChange={onPlaybackStateChanged}
onPictureInPictureChange={_onPictureInPictureChange} onPictureInPictureChange={_onPictureInPictureChange}
syncPlayDelegated={isSyncPlayEnabled}
onPipPlayRequest={_onPipPlayRequest}
onPipPauseRequest={_onPipPauseRequest}
onPipSkipRequest={_onPipSkipRequest}
onLoad={() => setIsVideoLoaded(true)} onLoad={() => setIsVideoLoaded(true)}
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => { onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);
@@ -1396,8 +1316,8 @@ export default function DirectPlayerPage() {
onSubtitleIndexChange={handleSubtitleIndexChange} onSubtitleIndexChange={handleSubtitleIndexChange}
previousItem={previousItem} previousItem={previousItem}
nextItem={nextItem} nextItem={nextItem}
goToPreviousItem={dispatchPreviousItem} goToPreviousItem={goToPreviousItem}
goToNextItem={dispatchNextItem} goToNextItem={goToNextItem}
onRefreshSubtitleTracks={handleRefreshSubtitleTracks} onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
addSubtitleFile={addSubtitleFile} addSubtitleFile={addSubtitleFile}
showTechnicalInfo={showTechnicalInfo} showTechnicalInfo={showTechnicalInfo}

View File

@@ -25,7 +25,6 @@ import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider"; import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { ServerUrlProvider } from "@/providers/ServerUrlProvider"; import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
import { SyncPlayProvider } from "@/providers/SyncPlay";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { import {
@@ -410,136 +409,133 @@ function Layout() {
<PlaySettingsProvider> <PlaySettingsProvider>
<LogProvider> <LogProvider>
<WebSocketProvider> <WebSocketProvider>
<SyncPlayProvider> <DownloadProvider>
<DownloadProvider> <MusicPlayerProvider>
<MusicPlayerProvider> <GlobalModalProvider>
<GlobalModalProvider> <BottomSheetModalProvider>
<BottomSheetModalProvider> <IntroSheetProvider>
<IntroSheetProvider> <ThemeProvider value={DarkTheme}>
<ThemeProvider value={DarkTheme}> <SystemBars style='light' hidden={false} />
<SystemBars style='light' hidden={false} /> <Stack initialRouteName='(auth)/(tabs)'>
<Stack initialRouteName='(auth)/(tabs)'> <Stack.Screen
<Stack.Screen name='(auth)/(tabs)'
name='(auth)/(tabs)' options={{
options={{ headerShown: false,
headerShown: false, title: "",
title: "", header: () => null,
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent:
Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}} }}
closeButton
/> />
{!Platform.isTV && <GlobalModal />} <Stack.Screen
</ThemeProvider> name='(auth)/player'
</IntroSheetProvider> options={{
</BottomSheetModalProvider> headerShown: false,
</GlobalModalProvider> title: "",
</MusicPlayerProvider> header: () => null,
</DownloadProvider> }}
</SyncPlayProvider> />
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider> </WebSocketProvider>
</LogProvider> </LogProvider>
</PlaySettingsProvider> </PlaySettingsProvider>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

BIN
assets/icons/link.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

BIN
assets/icons/list.star.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" fill="none" viewBox="0 0 96 96"><path fill="url(#paint0_linear)" fill-rule="evenodd" d="M48 96C74.5097 96 96 74.5097 96 48C96 21.4903 74.5097 0 48 0C21.4903 0 0 21.4903 0 48C0 74.5097 21.4903 96 48 96ZM80.0001 52C80.0001 67.464 67.4641 80 52.0001 80C36.5361 80 24.0001 67.464 24.0001 52C24.0001 49.1303 24.4318 46.3615 25.2338 43.7548C27.4288 48.6165 32.3194 52 38.0001 52C45.7321 52 52.0001 45.732 52.0001 38C52.0001 32.3192 48.6166 27.4287 43.755 25.2337C46.3616 24.4317 49.1304 24 52.0001 24C67.4641 24 80.0001 36.536 80.0001 52Z" clip-rule="evenodd"/><path fill="#131928" fill-rule="evenodd" d="M80.0002 52C80.0002 67.464 67.4642 80 52.0002 80C36.864 80 24.5329 67.9897 24.017 52.9791C24.0057 53.318 24 53.6583 24 54C24 70.5685 37.4315 84 54 84C70.5685 84 84 70.5685 84 54C84 37.4315 70.5685 24 54 24C53.6597 24 53.3207 24.0057 52.9831 24.0169C67.9919 24.5347 80.0002 36.865 80.0002 52Z" clip-rule="evenodd" opacity=".2"/><path fill="url(#paint1_linear)" fill-rule="evenodd" d="M48 12C28.1177 12 12 28.1177 12 48C12 50.2091 10.2091 52 8 52C5.79086 52 4 50.2091 4 48C4 23.6995 23.6995 4 48 4C50.2091 4 52 5.79086 52 8C52 10.2091 50.2091 12 48 12Z" clip-rule="evenodd"/><defs><linearGradient id="paint0_linear" x1="48" x2="117.5" y1="0" y2="69.5" gradientUnits="userSpaceOnUse"><stop stop-color="#C395FC"/><stop offset="1" stop-color="#4F65F5"/></linearGradient><linearGradient id="paint1_linear" x1="28" x2="28" y1="8" y2="48" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" stop-opacity=".4"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,65 +0,0 @@
<svg
type="certified"
viewBox="0 0 80 80"
preserveAspectRatio="xMidYMid"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<g transform="translate(2.29, 0)">
<path
d="M42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.6297143,21.1451429 51.5085714,21.4605714 51.3097143,21.408 C47.8902857,20.4868571 42.5577143,25.0217143 39.1017143,22.0891429 C39.008,22.9485714 38.2331429,27.0857143 32.3314286,26.4731429 C32.192,26.4594286 32.1371429,26.304 32.24,26.2171429 C33.1542857,25.44 34.2765714,23.2891429 33.3142857,21.9154286 C30.3108571,23.9085714 28.7565714,23.9954286 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.9222857 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.312 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8354286 C28.224,15.3188571 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.7862857 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6925714 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857"
id="Fill-2"
fill="#00912D"
></path>
<mask id="mask-2" fill="white">
<polygon
points="0.137142857 0.921142857 75.0534777 0.921142857 75.0534777 79.8628571 0.137142857 79.8628571"
></polygon>
</mask>
<path
d="M13.0491429,59.1817143 C9.90628571,55.3554286 7.86971429,50.576 7.51771429,44.9622857 C6.912,35.2342857 10.2354286,26.0845714 23.1794286,21.4834286 C23.1908571,21.5245714 23.1725714,21.5748571 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.92 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.3097143 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8331429 C28.224,15.3165714 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.784 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6902857 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.328,20.8502857 51.1337143,20.7245714 50.9508571,20.5874286 C60.2765714,23.504 66.7474286,30.1531429 67.44,41.2251429 C67.8811429,48.2948571 65.5702857,54.3885714 61.568,59.1154286 C62.784,59.2891429 63.9931429,59.4925714 65.2045714,59.6937143 C70.304,53.4537143 73.2502857,45.5428571 73.2502857,37.056 C73.2502857,17.7165714 57.5337143,2.56685714 37.472,2.56685714 C17.4102857,2.56685714 1.69371429,17.7165714 1.69371429,37.056 C1.69371429,45.5565714 4.64,53.472 9.744,59.7097143 C10.8434286,59.5268571 11.9451429,59.3462857 13.0491429,59.1817143"
fill="#FFD700"
mask="url(#mask-2)"
></path>
<path
d="M9.744,59.7097143 C4.64,53.472 1.69371429,45.5565714 1.69371429,37.056 C1.69371429,17.7165714 17.4102857,2.56685714 37.472,2.56685714 C57.5337143,2.56685714 73.2502857,17.7165714 73.2502857,37.056 C73.2502857,45.5428571 70.304,53.4537143 65.2045714,59.6937143 C65.8125714,59.7942857 66.4205714,59.8742857 67.0285714,59.984 C71.9497143,53.6457143 74.8937143,45.6982857 74.8937143,37.056 C74.8937143,16.3862857 58.1394286,0.921142857 37.472,0.921142857 C16.8022857,0.921142857 0.048,16.3862857 0.048,37.056 C0.048,45.7074286 2.99885714,53.6594286 7.92914286,59.9977143 C8.53257143,59.8902857 9.13828571,59.8102857 9.744,59.7097143"
fill="#FA6E0F"
mask="url(#mask-2)"
></path>
<path
d="M58.2857143,74.9394286 C62.3748571,75.1954286 65.7874286,77.2137143 67.8468571,79.9474286 C67.9131429,80.0182857 68.0114286,80.016 68.0411429,79.9382857 C68.7451429,77.0971429 68.9394286,74.0662857 68.5851429,71.0125714 C68.5874286,70.9805714 68.6125714,70.9577143 68.6537143,70.9485714 C70.576,70.3428571 72.7017143,70.0137143 74.9645714,70.0457143 C75.0857143,70.0594286 75.0834286,69.9405714 74.9554286,69.8194286 C72.5577143,67.4994286 69.6297143,65.6914286 66.416,64.5417143 C65.3051429,67.68 64.2217143,70.816 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286 L58.2857143,74.9394286"
fill="#0AC855"
mask="url(#mask-2)"
></path>
<path
d="M62.9645714,74.0434286 L58.2857143,74.9394286 C58.2857143,74.9394286 58.3451429,74.512 58.528,73.3325714 C60.9417143,73.6754286 62.9645714,74.0434286 62.9645714,74.0434286"
fill="#0B4902"
></path>
<g transform="translate(0, 20.57)">
<mask id="mask-4" fill="white">
<polygon
points="0.137142857 0.016 67.4935952 0.016 67.4935952 59.2914286 0.137142857 59.2914286"
></polygon>
</mask>
<path
d="M13.0765714,38.6057143 C29.1177143,36.2605714 45.5222857,36.2354286 61.568,38.544 C65.5702857,33.8171429 67.8811429,27.7234286 67.44,20.6537143 C66.7474286,9.58171429 60.2765714,2.93257143 50.9508571,0.016 C51.1337143,0.153142857 51.328,0.278857143 51.4902857,0.434285714 C51.6297143,0.573714286 51.5085714,0.889142857 51.3097143,0.836571429 C47.8902857,-0.0845714286 42.5577143,4.45028571 39.1017143,1.51771429 C39.008,2.37485714 38.2331429,6.51428571 32.3314286,5.90171429 C32.192,5.888 32.1371429,5.73257143 32.24,5.64571429 C33.1542857,4.86857143 34.2765714,2.71542857 33.3142857,1.344 C30.3108571,3.33714286 28.7565714,3.424 23.2182857,1.024 C23.1725714,1.00342857 23.1908571,0.953142857 23.1794286,0.912 C10.2354286,5.51314286 6.912,14.6628571 7.51771429,24.3908571 C7.86971429,30.0091429 9.93142857,34.7748571 13.0765714,38.6057143"
fill="#FA3200"
mask="url(#mask-4)"
></path>
<path
d="M12.0868571,53.472 C12,53.488 11.9154286,53.4514286 11.8948571,53.392 C10.8274286,50.2445714 9.73485714,47.0971429 8.62171429,43.9611429 C5.41028571,45.1108571 2.49371429,46.9302857 0.0982857143,49.248 C-0.0297142857,49.3691429 -0.032,49.488 0.0891428571,49.4742857 C2.352,49.4422857 4.47771429,49.7714286 6.4,50.3771429 C6.44114286,50.3862857 6.46628571,50.4091429 6.46857143,50.4411429 C6.11428571,53.4948571 6.30857143,56.5257143 7.01257143,59.3668571 C7.04228571,59.4445714 7.14057143,59.4468571 7.20685714,59.376 C9.26628571,56.6422857 12.6742857,54.624 16.7657143,54.368 L12.0868571,53.472"
fill="#0AC855"
mask="url(#mask-4)"
></path>
</g>
<path
d="M62.9645714,74.0434286 C46.192,71.104 28.8571429,71.104 12.0868571,74.0434286 C12,74.0594286 11.9154286,74.0228571 11.8948571,73.9634286 C10.3428571,69.3851429 8.74285714,64.8182857 7.09257143,60.2628571 C7.06971429,60.1988571 7.14057143,60.1257143 7.248,60.1074286 C27.1885714,56.464 47.8605714,56.464 67.8034286,60.1074286 C67.9108571,60.1257143 67.9817143,60.1988571 67.9565714,60.2628571 C66.3085714,64.8182857 64.7085714,69.3851429 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286"
fill="#00912D"
></path>
<path
d="M12.0868571,74.0434286 L16.7657143,74.9394286 C16.7657143,74.9394286 16.704,74.512 16.5211429,73.3325714 C14.1074286,73.6754286 12.0868571,74.0434286 12.0868571,74.0434286"
fill="#0B4902"
></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path fill="#fff" d="M370.57 474.214l23.466-237.956c14.93-4.796 29.498-11.15 40.23-20.262L404.16 446.278c-6.748 10.248-19.863 20.86-33.59 27.936zm-78.197 21.631l2.947-244.528c20.894-.599 47.933-3.43 70.97-8.346l-19.07 241.17c-22.724 7.518-35.934 9.848-54.847 11.704zm-99.694-252.874c23.038 4.916 50.077 7.747 70.971 8.346l2.948 244.528c-18.914-1.856-32.123-4.186-54.847-11.705l-19.072-241.17zm-67.974-26.975c10.732 9.112 25.3 15.466 40.23 20.262l23.464 237.956c-13.726-7.075-26.84-17.688-33.59-27.936l-30.104-230.282z"/><path fill="gold" d="M118.905 157.445c1.357 28.827 72.771 51.677 160.578 51.176 76.687-.438 140.659-18.546 156.329-42.336a22.976 22.976 0 00-14.058-7.426c.06-.7.098-1.406.095-2.122-.065-11.4-8.429-20.788-19.327-22.54.287-1.474.438-2.999.43-4.559-.072-12.696-10.426-22.928-23.124-22.856-.287.001-.568.036-.853.049a22.911 22.911 0 001.254-7.56c-.074-12.697-10.425-22.93-23.123-22.858a22.914 22.914 0 00-8.247 1.6c-3.632-6.835-10.606-11.6-18.737-12.149-1.416-11.4-11.157-20.195-22.93-20.129-7.41.042-13.963 3.6-18.136 9.065-4.233-4.605-10.3-7.494-17.047-7.456-12.698.072-22.932 10.424-22.86 23.118a22.983 22.983 0 001.115 6.946 22.918 22.918 0 00-13.07 7.459c-2.644-9.847-11.637-17.084-22.314-17.024-9.975.057-18.406 6.47-21.537 15.366-8.474 3.426-14.439 11.738-14.383 21.433.012 2.154.342 4.227.907 6.202a22.876 22.876 0 00-9.328-1.932c-10.012.058-18.47 6.516-21.574 15.465a22.83 22.83 0 00-9.788-2.149c-12.698.072-22.934 10.422-22.86 23.118a22.833 22.833 0 003.159 11.463c-.202.203-.379.426-.571.636"/><path fill="#FA320A" d="M404.161 446.278c-6.749 10.248-19.864 20.86-33.59 27.936l23.465-237.956c14.93-4.796 29.498-11.15 40.23-20.262L404.16 446.278zM347.22 484.14c-22.723 7.519-35.934 9.85-54.847 11.705l2.947-244.528c20.894-.599 47.933-3.43 70.973-8.346L347.22 484.14zm-135.47 0l-19.07-241.17c23.037 4.917 50.076 7.748 70.97 8.347l2.948 244.528c-18.914-1.856-32.123-4.186-54.847-11.705zm-56.94-37.862l-30.105-230.282c10.732 9.112 25.3 15.466 40.23 20.262l23.464 237.956c-13.726-7.075-26.84-17.688-33.588-27.936zm247.668-321.143c.298 1.453.465 2.955.473 4.498a23.018 23.018 0 01-.43 4.56c10.9 1.749 19.263 11.137 19.328 22.54a23.59 23.59 0 01-.095 2.12 22.976 22.976 0 0114.058 7.425c-15.669 23.792-79.642 41.9-156.327 42.34-87.807.502-159.221-22.346-160.58-51.175.192-.208.37-.433.57-.634-1.355-2.311-2.29-4.887-2.773-7.62-8.408 7.979-13.495 14.412-12.6 23.78.085 1.251 37.196 266.911 37.196 266.911 4.282 42.075 65.391 75.703 138.187 76.12 72.796-.417 133.907-34.045 138.187-76.12 0 0 37.11-265.66 37.197-266.912 1.777-18.736-20.15-35.745-52.39-47.833z"/></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><g transform="translate(33 140)"><path d="m43.802 267.32l237.94 23.482c4.7937 14.937 11.149 29.517 20.259 40.256l-230.27-30.125c-10.248-6.7519-20.861-19.877-27.936-33.612zm222.88-75.298c0.60053 20.906 3.4316 47.964 8.3462 71.017l-241.15-19.083c-7.518-22.739-9.8466-35.959-11.704-54.885l244.51 2.951zm8.3462-102.71c-4.9146 23.053-7.7456 50.111-8.3462 71.017l-244.51 2.951c1.8576-18.926 4.1862-32.146 11.704-54.885l241.15-19.083zm26.973-68.019c-9.1095 10.74-15.465 25.318-20.259 40.257l-237.94 23.48c7.0751-13.735 17.689-26.859 27.936-33.612l230.27-30.125z" fill="#fff"/><path d="m303.57 264.67c3.155-7.8209 14.337-12.586 22.367-12.028 8.5825 0.59581 17.699 9.6258 19.292 18.507 0.29589-0.32244 0.60578-0.62735 0.92093-0.92701 2.7558-2.6356 6.2084-4.3845 9.9867-4.8664-0.57777-2.562-0.71609-5.3045-0.3204-8.1188 1.3954-9.901 9.3336-17.326 18.422-17.252 5.8652 0.047314 11.011 3.0509 14.364 7.6649 0.29939-0.37501 0.6303-0.71848 0.95245-1.069 3.8343-19.991 6.2644-42.578 6.8177-66.547 1.8996-82.42-18.993-149.75-46.663-150.39-27.672-0.63962-51.644 65.656-53.544 148.08 0 0-1.4654 30.062 7.4042 86.951" fill="#00641E"/><path d="m490.91 354.8c1.6353-2.732 2.5492-6.0072 2.4862-9.4874 0.5305-11.245-7.1819-21.439-17.913-20.31 0.3099-1.2862 0.51299-2.6233 0.59178-4.0024 0.64255-11.264-7.1188-20.972-17.337-21.682-0.2241-0.014019-0.44471-0.021029-0.66706-0.028039 1.0627-2.795 1.5897-5.916 1.4024-9.2228-0.52875-9.3717-6.9858-17.268-15.386-18.822-3.0342-0.56076-5.9773-0.28213-8.6718 0.65188-2.5457-6.277-7.8909-10.933-14.393-11.916-0.51474-10.192-7.8699-18.58-17.34-19.238-5.9545-0.41356-11.426 2.3237-15.085 6.9061-3.3528-4.614-8.4985-7.6158-14.364-7.6632-9.0885-0.075352-17.027 7.3495-18.422 17.252-0.39569 2.8126-0.25737 5.555 0.3204 8.1188-3.7783 0.48015-7.2309 2.2308-9.9867 4.8646-0.31515 0.29966-0.62504 0.60457-0.92093 0.92701-1.5932-8.8811-10.71-17.909-19.292-18.507-8.0293-0.55726-19.357 4.3249-22.367 12.028 1.3201 13.434 9.71 50.053 40.055 82.903l0.26963 0.019276c2.9256 2.6496 6.7459 4.1093 10.818 3.7466 2.5247-0.22606 4.8498-1.1303 6.8527-2.5252l0.48848 0.033295c2.67 1.8558 5.8793 2.8266 9.2706 2.5252 1.2956-0.11566 2.5317-0.42758 3.7065-0.87269 2.9064 6.0142 9.3879 9.901 16.622 9.2631 5.6026-0.49417 10.365-3.5959 13.164-7.9611l0.90692 0.063086c2.7961 2.774 6.513 4.3897 10.522 4.2688 3.3143 5.0188 9.4019 8.1065 16.12 7.516 2.5299-0.22255 4.8918-0.95505 6.998-2.0643 3.5139 4.3266 9.2811 6.8991 15.609 6.3419 6.2557-0.5485 11.54-4.02 14.414-8.8197 2.8241 2.2693 6.3625 3.4872 10.12 3.1525 3.6452-0.32594 6.8842-2.0485 9.3179-4.6543l0.40619 0.028038c0.55326-0.80259 1.026-1.6245 1.4654-2.4533 0.010505-0.015772 0.019259-0.033296 0.028014-0.049067 0.059527-0.1104 0.13306-0.21905 0.18909-0.3312" fill="#FFD700"/><path d="m281.75 61.547l-237.94 23.48c7.0751-13.735 17.689-26.859 27.936-33.612l230.27-30.125c-9.1095 10.74-15.465 25.318-20.259 40.257zm20.259 269.51l-230.27-30.125c-10.248-6.7519-20.861-19.877-27.936-33.611l237.94 23.48c4.7937 14.937 11.149 29.517 20.259 40.256zm-268.13-87.102c-7.518-22.739-9.8466-35.957-11.704-54.885l244.51 2.951c0.60053 20.906 3.4316 47.964 8.3462 71.017l-241.15-19.083zm0-135.56l241.15-19.083c-4.9146 23.053-7.7456 50.111-8.3462 71.019l-244.51 2.9493c1.8576-18.926 4.1862-32.146 11.704-54.885zm344.72-82.679c-15.255-17.778-26.206-26.124-35.587-25.04-1.7491 0.22255-266.89 37.222-266.89 37.222-42.074 4.2828-75.7 65.432-76.117 138.28 0.4167 72.843 34.043 133.99 76.117 138.28 0 0 265.64 37.135 266.89 37.221 2.2183-0.014019 4.4086-0.31192 6.5673-0.86568-2.101-0.6256-4.0391-1.7208-5.6867-3.2139l-0.26963-0.019276c-30.345-32.848-38.735-69.47-40.055-82.903 0.003501-0.010514 0.010505-0.019276 0.014006-0.02979-0.003501 0.010514-0.010505 0.019276-0.014006 0.02979-8.8697-56.889-7.4042-86.951-7.4042-86.951 1.8996-82.42 25.872-148.72 53.544-148.08 27.67 0.63962 48.562 67.973 46.663 150.39-0.55326 23.969-2.9834 46.556-6.8177 66.547 3.9393-4.3512 9.2233-6.1842 14.133-5.8372 0.90342 0.064838 1.7823 0.2173 2.6437 0.41531 16.804-92.03-0.81238-181.89-27.729-215.44z" fill="#04A53C"/></g></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path d="m478.29 296.98c-3.99-63.966-36.52-111.82-85.468-138.58 0.278 1.56-1.109 3.508-2.688 2.818-32.016-14.006-86.328 31.32-124.28 7.584 0.285 8.519-1.378 50.072-59.914 52.483-1.382 0.056-2.142-1.355-1.268-2.354 7.828-8.929 15.732-31.535 4.367-43.586-24.338 21.81-38.472 30.017-85.138 19.186-29.878 31.241-46.809 74-43.485 127.26 6.78 108.74 108.63 170.89 211.19 164.49 102.56-6.395 193.47-80.572 186.68-189.31" fill="#FA320A"/><path d="M291.375 132.293c21.075-5.023 81.693-.49 101.114 25.274 1.166 1.545-.475 4.468-2.355 3.648-32.016-14.006-86.328 31.32-124.282 7.584.285 8.519-1.378 50.072-59.914 52.483-1.382.056-2.142-1.355-1.268-2.354 7.828-8.929 15.73-31.535 4.367-43.586-26.512 23.758-40.884 31.392-98.426 15.838-1.883-.508-1.241-3.535.762-4.298 10.876-4.157 35.515-22.361 58.824-30.385 4.438-1.526 8.862-2.71 13.18-3.4-25.665-2.293-37.235-5.862-53.559-3.4-1.789.27-3.004-1.813-1.895-3.241 21.995-28.332 62.513-36.888 87.512-21.837-15.41-19.094-27.48-34.321-27.48-34.321l28.601-16.246s11.817 26.4 20.414 45.614c21.275-31.435 60.86-34.336 77.585-12.033.992 1.326-.045 3.21-1.702 3.171-13.612-.331-21.107 12.05-21.675 21.466l.197.023" fill="#00912D"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path d="M445.185 444.684c-79.369 4.167-95.587-86.652-126.726-86.006-13.268.279-23.726 14.151-19.133 30.32 2.525 8.888 9.53 21.923 13.944 30.011 15.57 28.544-7.447 60.845-34.383 63.577-44.76 4.54-63.433-21.426-62.278-48.007 1.3-29.84 26.6-60.331.65-73.305-27.194-13.597-49.301 39.572-75.325 51.439-23.553 10.741-56.248 2.413-67.872-23.741-8.164-18.379-6.68-53.768 29.67-67.27 22.706-8.433 73.305 11.029 75.9-13.623 2.992-28.416-53.155-30.812-70.06-37.626-29.912-12.055-47.567-37.85-33.734-65.522 10.378-20.757 40.915-29.203 64.223-20.11 27.922 10.892 32.404 39.853 46.71 51.897 12.324 10.38 29.19 11.68 40.22 4.543 8.135-5.265 10.843-16.828 7.774-27.39-4.07-14.023-14.875-22.773-25.415-31.346-18.758-15.249-45.24-28.36-29.222-69.983 13.13-34.11 51.642-35.34 51.642-35.34 15.3-1.72 29.002 2.9 40.167 12.875 14.927 13.335 17.834 31.16 15.336 50.176-2.283 17.358-8.426 32.56-11.63 49.759-3.717 19.966 6.954 40.086 27.249 40.869 26.694 1.031 34.698-19.486 37.964-32.492 4.782-19.028 11.058-36.694 28.718-47.82 25.346-15.97 60.552-12.47 76.886 18.222 12.92 24.284 8.772 57.715-11.047 75.97-8.892 8.188-19.584 11.075-31.148 11.156-16.585.117-33.162-.29-48.556 7.471-10.48 5.281-15.047 13.888-15.045 25.423 0 11.242 5.853 18.585 15.336 23.363 17.86 9.003 37.577 10.843 56.871 14.222 27.98 4.9 52.581 14.755 68.375 40.72.142.228.28.458.415.69 18.139 30.741-.831 75.005-36.476 76.878" fill="#0AC855"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 190.24 81.52"><defs><linearGradient id="a" y1="40.76" x2="190.24" y2="40.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset=".56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><path d="M105.67 36.06h66.9a17.67 17.67 0 0017.67-17.66A17.67 17.67 0 00172.57.73h-66.9A17.67 17.67 0 0088 18.4a17.67 17.67 0 0017.67 17.66zm-88 45h76.9a17.67 17.67 0 0017.67-17.66 17.67 17.67 0 00-17.67-17.67h-76.9A17.67 17.67 0 000 63.4a17.67 17.67 0 0017.67 17.66zm-7.26-45.64h7.8V6.92h10.1V0h-28v6.9h10.1zm28.1 0h7.8V8.25h.1l9 27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2 23.1h-.1L50.31 0h-11.8zm113.92 20.25a15.07 15.07 0 00-4.52-5.52 18.57 18.57 0 00-6.68-3.08 33.54 33.54 0 00-8.07-1h-11.7v35.4h12.75a24.58 24.58 0 007.55-1.15 19.34 19.34 0 006.35-3.32 16.27 16.27 0 004.37-5.5 16.91 16.91 0 001.63-7.58 18.5 18.5 0 00-1.68-8.25zM145 68.6a8.8 8.8 0 01-2.64 3.4 10.7 10.7 0 01-4 1.82 21.57 21.57 0 01-5 .55h-4.05v-21h4.6a17 17 0 014.67.63 11.66 11.66 0 013.88 1.87A9.14 9.14 0 01145 59a9.87 9.87 0 011 4.52 11.89 11.89 0 01-1 5.08zm44.63-.13a8 8 0 00-1.58-2.62 8.38 8.38 0 00-2.42-1.85 10.31 10.31 0 00-3.17-1v-.1a9.22 9.22 0 004.42-2.82 7.43 7.43 0 001.68-5 8.42 8.42 0 00-1.15-4.65 8.09 8.09 0 00-3-2.72 12.56 12.56 0 00-4.18-1.3 32.84 32.84 0 00-4.62-.33h-13.2v35.4h14.5a22.41 22.41 0 004.72-.5 13.53 13.53 0 004.28-1.65 9.42 9.42 0 003.1-3 8.52 8.52 0 001.2-4.68 9.39 9.39 0 00-.55-3.18zm-19.42-15.75h5.3a10 10 0 011.85.18 6.18 6.18 0 011.7.57 3.39 3.39 0 011.22 1.13 3.22 3.22 0 01.48 1.82 3.63 3.63 0 01-.43 1.8 3.4 3.4 0 01-1.12 1.2 4.92 4.92 0 01-1.58.65 7.51 7.51 0 01-1.77.2h-5.65zm11.72 20a3.9 3.9 0 01-1.22 1.3 4.64 4.64 0 01-1.68.7 8.18 8.18 0 01-1.82.2h-7v-8h5.9a15.35 15.35 0 012 .15 8.47 8.47 0 012.05.55 4 4 0 011.57 1.18 3.11 3.11 0 01.63 2 3.71 3.71 0 01-.43 1.92z" fill="url(#a)"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,4 +1,3 @@
export * from "./api"; export * from "./api";
export * from "./mmkv"; export * from "./mmkv";
export * from "./number"; export * from "./number";
export * from "./string";

View File

@@ -3,7 +3,6 @@ declare global {
bytesToReadable(decimals?: number): string; bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number; secondsToMilliseconds(): number;
minutesToMilliseconds(): number; minutesToMilliseconds(): number;
hoursToMilliseconds(): number;
} }
} }
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds(); return this.valueOf() * (60).secondsToMilliseconds();
}; };
Number.prototype.hoursToMilliseconds = function () {
return this.valueOf() * (60).minutesToMilliseconds();
};
export {}; export {};

View File

@@ -1,14 +0,0 @@
declare global {
interface String {
toTitle(): string;
}
}
String.prototype.toTitle = function () {
return this.replaceAll("_", " ").replace(
/\w\S*/g,
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
);
};
export {};

View File

@@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "streamyfin", "name": "streamyfin",
@@ -107,7 +108,7 @@
"@types/react": "~19.2.10", "@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0", "@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0", "cross-env": "10.1.0",
"expo-doctor": "1.19.7", "expo-doctor": "1.19.9",
"husky": "9.1.7", "husky": "9.1.7",
"lint-staged": "17.0.5", "lint-staged": "17.0.5",
"react-test-renderer": "19.2.3", "react-test-renderer": "19.2.3",
@@ -968,7 +969,7 @@
"expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="], "expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="],
"expo-doctor": ["expo-doctor@1.19.7", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-pzn7QtCifRlvGIQz8k7kszeYFaI5Yn81WTHlk/20tmd3jwnXxPjlcdyhFSkuRtO2v4a9gA/6aUWVBOosfffj9w=="], "expo-doctor": ["expo-doctor@1.19.9", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-SJW5HxEDQ9f5QdFvrUwfbdJZ4HI0EAAxsrJqrHBFjKBum+uSOcEIZPLRibwNQLTHOwTO1TWNLiMlF9sDUBWeYw=="],
"expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="], "expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="],
@@ -1598,7 +1599,7 @@
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="], "react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"], "react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd", "sha512-vfkld2jUj7EPkAjIc/Vbx4Q4MtOOLmYtCYCE2dWJsyLnPqgj1f0xVzBxbeVP7dfT+eSh4KIXfdxESXaHgrXIlw=="],
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="], "react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],

View File

@@ -1,203 +0,0 @@
/**
* Example Usage of Global Modal
*
* This file demonstrates how to use the global modal system from anywhere in your app.
* You can delete this file after understanding how it works.
*/
import { Ionicons } from "@expo/vector-icons";
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
/**
* Example 1: Simple Content Modal
*/
export const SimpleModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6'>
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
<Text className='text-white mb-4'>
This is a simple modal with just some text content.
</Text>
<Text className='text-neutral-400'>
Swipe down or tap outside to close.
</Text>
</View>,
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-purple-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Open Simple Modal</Text>
</TouchableOpacity>
);
};
/**
* Example 2: Modal with Custom Snap Points
*/
export const CustomSnapPointsExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6' style={{ minHeight: 400 }}>
<Text className='text-2xl font-bold mb-4 text-white'>
Custom Snap Points
</Text>
<Text className='text-white mb-4'>
This modal has custom snap points (25%, 50%, 90%).
</Text>
<View className='bg-neutral-800 p-4 rounded-lg'>
<Text className='text-white'>
Try dragging the modal to different heights!
</Text>
</View>
</View>,
{
snapPoints: ["25%", "50%", "90%"],
enableDynamicSizing: false,
},
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-blue-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Custom Snap Points</Text>
</TouchableOpacity>
);
};
/**
* Example 3: Complex Component in Modal
*/
const SettingsModalContent = () => {
const { hideModal } = useGlobalModal();
const settings = [
{
id: 1,
title: "Notifications",
icon: "notifications-outline" as const,
enabled: true,
},
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
{
id: 3,
title: "Auto-play",
icon: "play-outline" as const,
enabled: false,
},
];
return (
<View className='p-6'>
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
{settings.map((setting, index) => (
<View
key={setting.id}
className={`flex-row items-center justify-between py-4 ${
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
}`}
>
<View className='flex-row items-center gap-3'>
<Ionicons name={setting.icon} size={24} color='white' />
<Text className='text-white text-lg'>{setting.title}</Text>
</View>
<View
className={`w-12 h-7 rounded-full ${
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
}`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
setting.enabled ? "translate-x-6" : "translate-x-1"
}`}
style={{ marginTop: 4 }}
/>
</View>
</View>
))}
<TouchableOpacity
onPress={hideModal}
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
>
<Text className='text-white font-semibold text-center'>Close</Text>
</TouchableOpacity>
</View>
);
};
export const ComplexModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(<SettingsModalContent />);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-green-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Complex Component</Text>
</TouchableOpacity>
);
};
/**
* Example 4: Modal Triggered from Function (e.g., API response)
*/
export const useShowSuccessModal = () => {
const { showModal } = useGlobalModal();
return (message: string) => {
showModal(
<View className='p-6 items-center'>
<View className='bg-green-500 rounded-full p-4 mb-4'>
<Ionicons name='checkmark' size={48} color='white' />
</View>
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
<Text className='text-white text-center'>{message}</Text>
</View>,
);
};
};
/**
* Main Demo Component
*/
export const GlobalModalDemo = () => {
const showSuccess = useShowSuccessModal();
return (
<View className='p-6 gap-4'>
<Text className='text-2xl font-bold mb-4 text-white'>
Global Modal Examples
</Text>
<SimpleModalExample />
<CustomSnapPointsExample />
<ComplexModalExample />
<TouchableOpacity
onPress={() => showSuccess("Operation completed successfully!")}
className='bg-orange-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Show Success Modal</Text>
</TouchableOpacity>
</View>
);
};

View File

@@ -89,14 +89,14 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
</Text> </Text>
<View className='flex flex-row items-center mt-4'> <View className='flex flex-row items-center mt-4'>
<Image <Image
source={require("@/assets/icons/jellyseerr-logo.svg")} source={require("@/assets/icons/seerr-logo.svg")}
style={{ style={{
width: 50, width: 50,
height: 50, height: 50,
}} }}
/> />
<View className='shrink ml-2'> <View className='shrink ml-2'>
<Text className='font-bold mb-1'>Jellyseerr</Text> <Text className='font-bold mb-1'>Seerr</Text>
<Text className='shrink text-xs'> <Text className='shrink text-xs'>
{t("home.intro.jellyseerr_feature_description")} {t("home.intro.jellyseerr_feature_description")}
</Text> </Text>

View File

@@ -23,8 +23,9 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation";
import { getDownloadedItemById } from "@/providers/Downloads/database"; import { getDownloadedItemById } from "@/providers/Downloads/database";
import { useGlobalModal } from "@/providers/GlobalModalProvider"; import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -66,44 +67,47 @@ export const PlayButton: React.FC<Props> = ({
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
// Single source of truth for all player navigation — SyncPlay,
// offline-vs-stream resolution, and the autoplay counter reset all
// live inside `playItem`.
const { playItem } = usePlayerItemNavigation();
// Use colors prop if provided, otherwise fallback to global atom // Use colors prop if provided, otherwise fallback to global atom
const effectiveColors = colors || globalColorAtom; const effectiveColors = colors || globalColorAtom;
const router = useRouter();
const startWidth = useSharedValue(0); const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0); const targetWidth = useSharedValue(0);
const endColor = useSharedValue(effectiveColors); const endColor = useSharedValue(effectiveColors);
const startColor = useSharedValue(effectiveColors); const startColor = useSharedValue(effectiveColors);
const widthProgress = useSharedValue(0); const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0);
const { settings } = useSettings(); const { settings, updateSettings } = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback( const goToPlayer = useCallback(
(opts: Parameters<typeof playItem>[1]) => { (q: string) => {
void playItem(item, opts); if (settings.maxAutoPlayEpisodeCount.value !== -1) {
updateSettings({ autoPlayEpisodeCount: 0 });
}
router.push(`/player/direct-player?${q}`);
}, },
[item, playItem], [router, isOffline],
); );
const handleNormalPlayFlow = useCallback(async () => { const handleNormalPlayFlow = useCallback(async () => {
if (!item) return; if (!item) return;
// Default play options derived from the page's track / source / bitrate const queryParams = new URLSearchParams({
// pickers. `playItem` handles SyncPlay broadcasting and offline-vs-online itemId: item.Id!,
// routing; we just need to pick a destination (device vs Chromecast). audioIndex: selectedOptions.audioIndex?.toString() ?? "",
const defaultOpts = { subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
audioIndex: selectedOptions.audioIndex, mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
subtitleIndex: selectedOptions.subtitleIndex, bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? undefined, playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
bitrateValue: selectedOptions.bitrate?.value, offline: isOffline ? "true" : "false",
}; });
const queryString = queryParams.toString();
if (!client) { if (!client) {
goToPlayer(defaultOpts); goToPlayer(queryString);
return; return;
} }
@@ -266,7 +270,7 @@ export const PlayButton: React.FC<Props> = ({
}); });
break; break;
case 1: case 1:
goToPlayer(defaultOpts); goToPlayer(queryString);
break; break;
case cancelButtonIndex: case cancelButtonIndex:
break; break;
@@ -276,24 +280,35 @@ export const PlayButton: React.FC<Props> = ({
}, [ }, [
item, item,
client, client,
settings,
api, api,
user, user,
router,
showActionSheetWithOptions, showActionSheetWithOptions,
mediaStatus, mediaStatus,
selectedOptions, selectedOptions,
goToPlayer, goToPlayer,
isOffline,
t, t,
]); ]);
const onPress = useCallback(async () => { const onPress = useCallback(async () => {
if (!item) return; if (!item) return;
lightHapticFeedback();
// Check if item is downloaded // Check if item is downloaded
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined; const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
// If already in offline mode, play downloaded file directly // If already in offline mode, play downloaded file directly
if (isOffline && downloadedItem) { if (isOffline && downloadedItem) {
goToPlayer({ forceOffline: true }); const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
return; return;
} }
@@ -316,7 +331,13 @@ export const PlayButton: React.FC<Props> = ({
<Button <Button
onPress={() => { onPress={() => {
hideModal(); hideModal();
goToPlayer({ forceOffline: true }); const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}} }}
color='purple' color='purple'
> >
@@ -353,7 +374,13 @@ export const PlayButton: React.FC<Props> = ({
{ {
text: t("player.downloaded_file_yes"), text: t("player.downloaded_file_yes"),
onPress: () => { onPress: () => {
goToPlayer({ forceOffline: true }); const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}, },
isPreferred: true, isPreferred: true,
}, },
@@ -377,12 +404,13 @@ export const PlayButton: React.FC<Props> = ({
handleNormalPlayFlow(); handleNormalPlayFlow();
}, [ }, [
item, item,
isOffline, lightHapticFeedback,
handleNormalPlayFlow, handleNormalPlayFlow,
goToPlayer, goToPlayer,
t, t,
showModal, showModal,
hideModal, hideModal,
effectiveColors,
]); ]);
const derivedTargetWidth = useDerivedValue(() => { const derivedTargetWidth = useDerivedValue(() => {

View File

@@ -40,8 +40,8 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
<Image <Image
source={ source={
item.CriticRating < 60 item.CriticRating < 60
? require("@/assets/images/rotten-tomatoes.png") ? require("@/assets/images/rt_rotten.svg")
: require("@/assets/images/not-rotten-tomatoes.svg") : require("@/assets/images/rt_fresh.svg")
} }
style={{ style={{
width: 14, width: 14,
@@ -89,8 +89,8 @@ export const JellyserrRatings: React.FC<{
className='mr-1' className='mr-1'
source={ source={
data?.criticsRating === "Rotten" data?.criticsRating === "Rotten"
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg") ? require("@/assets/images/rt_rotten.svg")
: require("@/utils/jellyseerr/src/assets/rt_fresh.svg") : require("@/assets/images/rt_fresh.svg")
} }
style={{ style={{
width: 14, width: 14,
@@ -109,8 +109,8 @@ export const JellyserrRatings: React.FC<{
className='mr-1' className='mr-1'
source={ source={
data?.audienceRating === "Spilled" data?.audienceRating === "Spilled"
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg") ? require("@/assets/images/rt_aud_rotten.svg")
: require("@/utils/jellyseerr/src/assets/rt_aud_fresh.svg") : require("@/assets/images/rt_aud_fresh.svg")
} }
style={{ style={{
width: 14, width: 14,
@@ -127,7 +127,7 @@ export const JellyserrRatings: React.FC<{
iconLeft={ iconLeft={
<Image <Image
className='mr-1' className='mr-1'
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")} source={require("@/assets/images/tmdb_logo.svg")}
style={{ style={{
width: 14, width: 14,
height: 14, height: 14,

View File

@@ -69,17 +69,23 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
[isAndroid], [isAndroid],
); );
const isPresentedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
bottomSheetModalRef.current?.present(); bottomSheetModalRef.current?.present();
} else { } else if (isPresentedRef.current) {
bottomSheetModalRef.current?.dismiss(); bottomSheetModalRef.current?.dismiss();
isPresentedRef.current = false;
} }
}, [visible]); }, [visible]);
const handleSheetChanges = useCallback( const handleSheetChanges = useCallback(
(index: number) => { (index: number) => {
if (index === -1) { if (index >= 0) {
isPresentedRef.current = true;
} else if (index === -1 && isPresentedRef.current) {
isPresentedRef.current = false;
resetState(); resetState();
onClose(); onClose();
} }

View File

@@ -26,7 +26,7 @@ export const TrackSheet: React.FC<Props> = ({
const streams = useMemo( const streams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === streamType), () => source?.MediaStreams?.filter((x) => x.Type === streamType),
[source], [source, streamType],
); );
const selectedSteam = useMemo( const selectedSteam = useMemo(
@@ -63,7 +63,7 @@ export const TrackSheet: React.FC<Props> = ({
<Text numberOfLines={1}> <Text numberOfLines={1}>
{selected === -1 && streamType === "Subtitle" {selected === -1 && streamType === "Subtitle"
? t("common.none") ? t("common.none")
: selectedSteam?.DisplayTitle || t("common.select", "Select")} : selectedSteam?.DisplayTitle || t("common.select")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View File

@@ -1,20 +0,0 @@
import { Image } from "expo-image";
import { View } from "react-native";
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
if (!url)
return (
<View className='p-4 rounded-xl overflow-hidden '>
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
</View>
);
return (
<View className='p-4 rounded-xl overflow-hidden '>
<Image
source={{ uri: url }}
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
/>
</View>
);
};

View File

@@ -1,28 +0,0 @@
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps {
index: number;
}
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
return (
<View
key={index}
style={{
width: "32%",
}}
className='flex flex-col'
{...props}
>
<View
style={{
aspectRatio: "10/15",
}}
className='w-full bg-neutral-800 mb-2 rounded-lg'
/>
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
</View>
);
};

View File

@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
api, api,
item: library, item: library,
}), }),
[library], [api, library],
); );
const itemType = useMemo(() => { const itemType = useMemo(() => {

View File

@@ -280,7 +280,7 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
<View style={styles.buttonContainer}> <View style={styles.buttonContainer}>
<TVSubmitButton <TVSubmitButton
onPress={handleSubmit} onPress={handleSubmit}
label={t("login.login")} label={t("login.login_button")}
loading={isLoading} loading={isLoading}
disabled={!password} disabled={!password}
/> />

View File

@@ -1,12 +0,0 @@
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
import Ionicons from "@expo/vector-icons/Ionicons";
import type { ComponentProps } from "react";
export function TabBarIcon({
style,
...rest
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
}

View File

@@ -1,63 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
type MoviePosterProps = {
item: BaseItemDto;
showProgress?: boolean;
};
export const EpisodePoster: React.FC<MoviePosterProps> = ({
item,
showProgress = false,
}) => {
const [api] = useAtom(apiAtom);
const url = useMemo(() => {
if (item.Type === "Episode") {
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
}
}, [item]);
const [progress, _setProgress] = useState(
item.UserData?.PlayedPercentage || 0,
);
const blurhash = useMemo(() => {
const key = item.ImageTags?.Primary as string;
return item.ImageBlurHashes?.Primary?.[key];
}, [item]);
return (
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
<Image
placeholder={{
blurhash,
}}
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
aspectRatio: "10/15",
width: "100%",
}}
/>
<WatchedIndicator item={item} />
{showProgress && progress > 0 && (
<View className='h-1 bg-red-600 w-full' />
)}
</View>
);
};

View File

@@ -1,48 +0,0 @@
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
type PosterProps = {
id?: string;
showProgress?: boolean;
};
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
const [api] = useAtom(apiAtom);
const url = useMemo(
() => `${api?.basePath}/Items/${id}/Images/Primary`,
[id],
);
if (!url || !id)
return (
<View
className='border border-neutral-900'
style={{
aspectRatio: "10/15",
}}
/>
);
return (
<View className='rounded-lg overflow-hidden border border-neutral-900'>
<Image
key={id}
id={id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
aspectRatio: "10/15",
}}
/>
</View>
);
};
export default ParentPoster;

View File

@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native"; import { Platform, ScrollView, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover"; import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
@@ -231,26 +231,48 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
paddingTop: insets.top + TOP_PADDING, paddingTop: insets.top + TOP_PADDING,
}} }}
> >
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search` {/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
module). It renders the native search bar + grid keyboard and TextInput fallback on Android TV (the native module is Apple-only). */}
forwards typed text into the existing query pipeline via setSearch; {Platform.OS === "ios" ? (
our own results grid renders below. */} <View
{/* No horizontal margin here: the native tvOS search bar centers itself style={{
and renders a trailing "Hold to Dictate in <Language>" hint. Extra marginBottom: 24,
margins squeeze the bar's width and clip that trailing hint, so let height: SEARCH_AREA_HEIGHT,
the native view span the full width and own its own insets. */} }}
<View >
style={{ {/* No horizontal margin here: the native tvOS search bar centers
marginBottom: 24, itself and renders a trailing "Hold to Dictate" hint. */}
height: SEARCH_AREA_HEIGHT, <TvSearchView
}} style={{ width: "100%", height: "100%" }}
> placeholder={t("search.search")}
<TvSearchView onChangeText={(e) => setSearch(e.nativeEvent.text)}
style={{ width: "100%", height: "100%" }} />
placeholder={t("search.search")} </View>
onChangeText={(e) => setSearch(e.nativeEvent.text)} ) : (
/> <View
</View> style={{
marginHorizontal: HORIZONTAL_PADDING,
marginBottom: 24,
}}
>
<TextInput
style={{
height: 56,
width: "100%",
backgroundColor: "#262626",
borderRadius: 12,
paddingHorizontal: 20,
fontSize: 28,
color: "#fff",
}}
placeholder={t("search.search")}
placeholderTextColor='rgba(255,255,255,0.4)'
onChangeText={setSearch}
defaultValue=''
autoFocus={false}
/>
</View>
)}
</View> </View>
<ScrollView <ScrollView

View File

@@ -1,29 +0,0 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const Dashboard = () => {
const { settings } = useSettings();
const { sessions = [] } = useSessions({} as useSessionsProps);
const router = useRouter();
const { t } = useTranslation();
if (!settings) return null;
return (
<View>
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
<ListItem
className={sessions.length !== 0 ? "bg-purple-900" : ""}
onPress={() => router.push("/settings/dashboard/sessions")}
title={t("home.settings.dashboard.sessions_title")}
showArrow
/>
</ListGroup>
</View>
);
};

View File

@@ -1,3 +0,0 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -1,3 +0,0 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -115,9 +115,6 @@ export const JellyseerrSettings = () => {
</> </>
) : ( ) : (
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'> <View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text>
<Text className='font-bold mb-1'> <Text className='font-bold mb-1'>
{t("home.settings.plugins.jellyseerr.server_url")} {t("home.settings.plugins.jellyseerr.server_url")}
</Text> </Text>

View File

@@ -1,8 +1,8 @@
import * as Application from "expo-application";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getVersionInfo } from "@/utils/version";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
@@ -13,10 +13,9 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const version = // Graduated build identifier — see utils/version.ts:
Application?.nativeApplicationVersion || // dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit · #run", production → "0.54.1".
Application?.nativeBuildVersion || const { display: version } = getVersionInfo();
"N/A";
return ( return (
<View {...props}> <View {...props}>

View File

@@ -1,259 +0,0 @@
/**
* GroupSelectionMenu
*
* Content rendered inside the SyncPlay bottom sheet (the sheet itself is
* owned by SyncPlayButton). Calls `onClose` after successful actions to
* dismiss the parent sheet.
*/
import { Ionicons } from "@expo/vector-icons";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useSyncPlay } from "@/providers/SyncPlay";
import type { GroupInfoDto } from "@/providers/SyncPlay/types";
interface GroupSelectionMenuProps {
onClose: () => void;
}
export function GroupSelectionMenu({ onClose }: GroupSelectionMenuProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const {
isEnabled,
groupInfo,
canCreateGroups,
joinGroup,
createGroup,
leaveGroup,
getGroups,
resumeGroupPlayback,
} = useSyncPlay();
const [groups, setGroups] = useState<GroupInfoDto[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
setIsLoading(true);
try {
const fetchedGroups = await getGroups();
if (!cancelled) {
setGroups(fetchedGroups);
}
} catch (error) {
console.error("Failed to fetch groups", error);
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [getGroups]);
const handleJoinGroup = useCallback(
async (groupId: string) => {
try {
await joinGroup(groupId);
onClose();
} catch (error) {
console.error("Failed to join group", error);
}
},
[joinGroup, onClose],
);
const handleCreateGroup = useCallback(async () => {
setIsCreating(true);
try {
await createGroup();
onClose();
} catch (error) {
console.error("Failed to create group", error);
} finally {
setIsCreating(false);
}
}, [createGroup, onClose]);
const handleLeaveGroup = useCallback(async () => {
try {
await leaveGroup();
onClose();
} catch (error) {
console.error("Failed to leave group", error);
}
}, [leaveGroup, onClose]);
// Jump (back) into the group's current item. Mirrors jellyfin-web's
// "Resume playback" menu entry — close the sheet and navigate to
// the player; SyncPlayProvider handles the re-follow + URL build.
const handleResumePlayback = useCallback(async () => {
try {
await resumeGroupPlayback();
onClose();
} catch (error) {
console.error("Failed to resume group playback", error);
}
}, [resumeGroupPlayback, onClose]);
const containerStyle = {
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
paddingBottom: Math.max(16, insets.bottom),
paddingTop: 8,
};
if (isEnabled && groupInfo) {
return (
<View style={containerStyle}>
<View className='mb-4'>
<View className='flex-row items-center mb-2'>
<Ionicons name='people' size={24} color='#00a4dc' />
<Text className='font-bold text-xl text-neutral-100 ml-2'>
{t("syncplay.title")}
</Text>
</View>
<Text className='text-neutral-400'>{t("syncplay.my_group")}</Text>
</View>
<View className='bg-neutral-800 rounded-xl p-4 mb-4'>
<View className='flex-row items-center justify-between mb-3'>
<Text className='text-neutral-100 font-semibold text-lg'>
{groupInfo.GroupName}
</Text>
<View className='bg-[#00a4dc] px-2 py-1 rounded'>
<Text className='text-white text-xs font-medium'>
{groupInfo.State}
</Text>
</View>
</View>
{groupInfo.Participants && groupInfo.Participants.length > 0 && (
<View className='flex-row items-center'>
<Ionicons name='person' size={16} color='#9ca3af' />
<Text className='text-neutral-400 ml-2'>
{groupInfo.Participants.length} {t("syncplay.members")}
</Text>
</View>
)}
</View>
<View className='mb-3'>
<Button onPress={handleResumePlayback} color='black'>
<View className='flex-row items-center justify-center'>
<Ionicons name='play-circle-outline' size={20} color='white' />
<Text className='text-white font-semibold ml-2'>
{t("syncplay.resume_playback")}
</Text>
</View>
</Button>
</View>
<Button onPress={handleLeaveGroup} color='red'>
<View className='flex-row items-center justify-center'>
<Ionicons name='exit-outline' size={20} color='white' />
<Text className='text-white font-semibold ml-2'>
{t("syncplay.leave_group")}
</Text>
</View>
</Button>
</View>
);
}
return (
<View style={containerStyle}>
<View className='mb-4'>
<View className='flex-row items-center mb-2'>
<Ionicons name='people-outline' size={24} color='white' />
<Text className='font-bold text-xl text-neutral-100 ml-2'>
{t("syncplay.title")}
</Text>
</View>
<Text className='text-neutral-400'>{t("syncplay.join_group")}</Text>
</View>
{isLoading && (
<View className='py-8 items-center'>
<ActivityIndicator color='#00a4dc' />
</View>
)}
{!isLoading && groups.length > 0 && (
<View className='mb-4'>
<Text className='text-neutral-400 text-sm mb-2 ml-1'>
{t("syncplay.available_groups")}
</Text>
<View className='bg-neutral-800 rounded-xl overflow-hidden'>
{groups.map((group, index) => (
<TouchableOpacity
key={group.GroupId ?? index}
onPress={() => group.GroupId && handleJoinGroup(group.GroupId)}
className={`flex-row items-center p-4 ${
index < groups.length - 1 ? "border-b border-neutral-700" : ""
}`}
>
<View className='w-10 h-10 bg-[#00a4dc]/20 rounded-full items-center justify-center mr-3'>
<Ionicons name='people' size={20} color='#00a4dc' />
</View>
<View className='flex-1'>
<Text className='text-neutral-100 font-medium'>
{group.GroupName}
</Text>
<Text className='text-neutral-500 text-sm'>
{group.Participants?.length ?? 0} {t("syncplay.members")} {" "}
{group.State}
</Text>
</View>
<Ionicons name='chevron-forward' size={20} color='#9ca3af' />
</TouchableOpacity>
))}
</View>
</View>
)}
{!isLoading && groups.length === 0 && (
<View className='bg-neutral-800/50 rounded-xl p-6 mb-4 items-center'>
<Ionicons name='people-outline' size={40} color='#6b7280' />
<Text className='text-neutral-400 text-center mt-3'>
{t("syncplay.available_groups")}: 0{"\n"}
{t("syncplay.create_new_group")}
</Text>
</View>
)}
{canCreateGroups && (
<Button
onPress={handleCreateGroup}
color='purple'
disabled={isCreating}
>
<View className='flex-row items-center justify-center'>
{isCreating ? (
<ActivityIndicator size='small' color='white' />
) : (
<>
<Ionicons name='add' size={20} color='white' />
<Text className='text-white font-semibold ml-2'>
{t("syncplay.create_new_group")}
</Text>
</>
)}
</View>
</Button>
)}
</View>
);
}

View File

@@ -1,206 +0,0 @@
/**
* SyncPlayActionIcon
*
* In-button SyncPlay status indicator — drops into the player's
* play/pause button slot and replaces the normal play/pause/loader
* graphic while SyncPlay is mid-transition. Mirrors jellyfin-web's
* `#syncPlayIcon` element (see `showIcon()` in
* `jellyfin-web/src/controllers/playback/video/index.js`).
*
* Icon vocabulary (matched 1:1 with jellyfin-web's `showIcon` switch):
*
* action primary secondary pulse spin
* --------------- ------------- ----------------- ---------- ----
* schedule-play sync play (centered) infinite yes
* unpause play-circle — one-shot no
* pause pause-circle — one-shot no
* seek refresh — infinite no
* buffering clock — infinite no
* wait-pause clock pause (shifted) infinite no
* wait-unpause clock play (shifted) infinite no
*
* Material → Ionicons mapping used here:
* sync → sync, schedule → time-outline, update → refresh-outline,
* play_arrow → play, pause → pause,
* play_circle_outline → play-circle-outline,
* pause_circle_outline → pause-circle-outline.
*
* When no SyncPlay action is active the component renders `fallback`
* so callers can keep the normal play/pause/loader graphic.
*/
import { Ionicons } from "@expo/vector-icons";
import { type ReactNode, useEffect } from "react";
import { StyleSheet, View } from "react-native";
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withTiming,
} from "react-native-reanimated";
import type { SyncPlayOsdAction } from "@/providers/SyncPlay";
import { useSyncPlay } from "@/providers/SyncPlay";
// SyncPlay cyan (matches jellyfin-web's `.syncPlayIconCircle` color)
const SYNC_PLAY_COLOR = "#00a4dc";
type IoniconName = keyof typeof Ionicons.glyphMap;
type SecondaryPosition = "centered" | "shifted";
interface SecondaryIcon {
icon: IoniconName;
position: SecondaryPosition;
}
interface OsdConfig {
/** Primary icon — fills the available size. */
icon: IoniconName;
/** Optional smaller overlay (~42% size). */
secondary?: SecondaryIcon;
/** Wrapper-level scale animation. */
pulse: "infinite" | "oneshot";
/** Rotate the primary icon continuously (secondary stays still). */
spin?: boolean;
}
const CONFIG: Record<SyncPlayOsdAction, OsdConfig> = {
"schedule-play": {
icon: "sync",
secondary: { icon: "play", position: "centered" },
pulse: "infinite",
spin: true,
},
unpause: { icon: "play-circle-outline", pulse: "oneshot" },
pause: { icon: "pause-circle-outline", pulse: "oneshot" },
seek: { icon: "refresh-outline", pulse: "infinite" },
buffering: { icon: "time-outline", pulse: "infinite" },
"wait-pause": {
icon: "time-outline",
secondary: { icon: "pause", position: "shifted" },
pulse: "infinite",
},
"wait-unpause": {
icon: "time-outline",
secondary: { icon: "play", position: "shifted" },
pulse: "infinite",
},
};
interface SyncPlayActionIconProps {
size: number;
color?: string;
/** Rendered when no SyncPlay action is active. */
fallback?: ReactNode;
}
export function SyncPlayActionIcon({
size,
color = SYNC_PLAY_COLOR,
fallback = null,
}: SyncPlayActionIconProps) {
const { osdAction } = useSyncPlay();
const rotation = useSharedValue(0);
const scale = useSharedValue(1);
useEffect(() => {
cancelAnimation(rotation);
cancelAnimation(scale);
rotation.value = 0;
scale.value = 1;
if (!osdAction) return;
const config = CONFIG[osdAction];
if (config.spin) {
rotation.value = withRepeat(
withTiming(360, { duration: 1200, easing: Easing.linear }),
-1,
false,
);
}
if (config.pulse === "infinite") {
scale.value = withRepeat(
withSequence(
withTiming(1.1, {
duration: 700,
easing: Easing.inOut(Easing.quad),
}),
withTiming(0.95, {
duration: 700,
easing: Easing.inOut(Easing.quad),
}),
),
-1,
true,
);
} else {
// one-shot: single scale flash; the provider clears the action
// ~1500ms later (transient OSD) so the icon then unmounts.
scale.value = withSequence(
withTiming(1.2, { duration: 220, easing: Easing.out(Easing.quad) }),
withTiming(1, { duration: 220, easing: Easing.inOut(Easing.quad) }),
);
}
}, [osdAction, rotation, scale]);
const pulseStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const spinStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
if (!osdAction) return <>{fallback}</>;
const config = CONFIG[osdAction];
const secondarySize = Math.round(size * 0.42);
// centered: geometric middle of the primary (e.g. play arrow inside
// the spinning `sync` ring for schedule-play).
// shifted: bottom-right corner (e.g. play/pause badge on the clock
// for wait-unpause / wait-pause).
const secondaryPosStyle =
config.secondary?.position === "centered"
? {
top: (size - secondarySize) / 2,
left: (size - secondarySize) / 2,
}
: { bottom: 0, right: 0 };
return (
<Animated.View style={pulseStyle}>
<View style={{ width: size, height: size }}>
<Animated.View style={[StyleSheet.absoluteFill, spinStyle]}>
<Ionicons name={config.icon} size={size} color={color} />
</Animated.View>
{config.secondary && (
<View
pointerEvents='none'
style={[styles.secondary, secondaryPosStyle]}
>
<Ionicons
name={config.secondary.icon}
size={secondarySize}
color={color}
/>
</View>
)}
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
secondary: {
position: "absolute",
},
});

View File

@@ -1,97 +0,0 @@
/**
* SyncPlayButton
*
* Header button for accessing SyncPlay functionality.
* Shows group status and opens the group selection sheet.
*
* Uses the @expo/ui drop-in BottomSheetModal (SwiftUI sheet on iOS, Jetpack
* Compose ModalBottomSheet on Android). Because it presents natively, it
* works correctly even when triggered from `headerRight` — no portal or
* provider context is required (unlike @gorhom/bottom-sheet, which fails
* silently from detached UINavigationItem subtrees).
*
* Safe to import statically: this whole module is lazy-required only on
* non-TV platforms by app/(auth)/(tabs)/(home)/_layout.tsx.
*/
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@expo/ui/community/bottom-sheet";
import { Ionicons } from "@expo/vector-icons";
import { useCallback, useRef } from "react";
import { Platform, View } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { useCastDevice } from "react-native-google-cast";
import { toast } from "sonner-native";
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
import { useSyncPlay } from "@/providers/SyncPlay";
import { GroupSelectionMenu } from "./GroupSelectionMenu";
interface SyncPlayButtonProps {
size?: number;
}
export function SyncPlayButton({ size = 22 }: SyncPlayButtonProps) {
const { isEnabled, canJoinGroups } = useSyncPlay();
const { isConnected } = useNetworkStatus();
const castDevice = useCastDevice();
const sheetRef = useRef<BottomSheetMethods>(null);
const isCasting = !!castDevice;
const handlePress = useCallback(() => {
if (isCasting) {
toast("SyncPlay not available while casting");
return;
}
sheetRef.current?.present();
}, [isCasting]);
const handleDismiss = useCallback(() => {
sheetRef.current?.dismiss();
}, []);
if (Platform.isTV) return null;
if (!canJoinGroups) return null;
if (!isConnected) return null;
const iconColor = isCasting ? "#6b7280" : isEnabled ? "#00a4dc" : "white";
return (
<>
<Pressable
className='mr-4'
onPress={handlePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<View className='relative'>
<Ionicons
name={isEnabled ? "people" : "people-outline"}
size={size}
color={iconColor}
/>
{isEnabled && !isCasting && (
<View
className='absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-[#00a4dc]'
style={{
borderWidth: 1,
borderColor: "#171717",
}}
/>
)}
</View>
</Pressable>
<BottomSheetModal
ref={sheetRef}
snapPoints={Platform.OS === "android" ? ["100%"] : ["60%"]}
enablePanDownToClose
>
<BottomSheetView>
<GroupSelectionMenu onClose={handleDismiss} />
</BottomSheetView>
</BottomSheetModal>
</>
);
}

View File

@@ -1,53 +0,0 @@
/**
* SyncPlaySpinner
*
* Compact rotating SyncPlay icon shown in place of the play/pause button
* while a play/pause command is in flight to the server (the "schedule-play"
* indicator from jellyfin-web).
*/
import { Ionicons } from "@expo/vector-icons";
import { useEffect } from "react";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
// SyncPlay cyan color (matches jellyfin-web)
const SYNC_PLAY_COLOR = "#00a4dc";
interface SyncPlaySpinnerProps {
size: number;
color?: string;
}
export function SyncPlaySpinner({
size,
color = SYNC_PLAY_COLOR,
}: SyncPlaySpinnerProps) {
const rotation = useSharedValue(0);
useEffect(() => {
rotation.value = withRepeat(
withTiming(360, {
duration: 1200,
easing: Easing.linear,
}),
-1,
false,
);
}, [rotation]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
return (
<Animated.View style={animatedStyle}>
<Ionicons name='sync' size={size} color={color} />
</Animated.View>
);
}

View File

@@ -1,8 +0,0 @@
/**
* SyncPlay UI Components
*/
export { GroupSelectionMenu } from "./GroupSelectionMenu";
export { SyncPlayActionIcon } from "./SyncPlayActionIcon";
export { SyncPlayButton } from "./SyncPlayButton";
export { SyncPlaySpinner } from "./SyncPlaySpinner";

View File

@@ -3,7 +3,6 @@ import type { FC } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { SyncPlayActionIcon } from "@/components/syncplay/SyncPlayActionIcon";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets"; import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import AudioSlider from "./AudioSlider"; import AudioSlider from "./AudioSlider";
@@ -122,20 +121,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}> <View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
<TouchableOpacity onPress={togglePlay}> <TouchableOpacity onPress={togglePlay}>
<SyncPlayActionIcon {!isBuffering ? (
size={ICON_SIZES.CENTER} <Ionicons
fallback={ name={isPlaying ? "pause" : "play"}
!isBuffering ? ( size={ICON_SIZES.CENTER}
<Ionicons color='white'
name={isPlaying ? "pause" : "play"} />
size={ICON_SIZES.CENTER} ) : (
color='white' <Loader size={"large"} />
/> )}
) : (
<Loader size={"large"} />
)
}
/>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View File

@@ -7,12 +7,14 @@ import useRouter from "@/hooks/useAppRouter";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export interface ContinueWatchingOverlayProps { export interface ContinueWatchingOverlayProps {
/** Invoked when the user confirms they want to keep watching. */ goToNextItem: (options: {
onContinue: () => void; isAutoPlay: boolean;
resetWatchCount: boolean;
}) => void;
} }
const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
onContinue, goToNextItem,
}) => { }) => {
const { settings } = useSettings(); const { settings } = useSettings();
const router = useRouter(); const router = useRouter();
@@ -27,7 +29,13 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
<Text className='text-2xl font-bold text-white py-4 '> <Text className='text-2xl font-bold text-white py-4 '>
Are you still watching ? Are you still watching ?
</Text> </Text>
<Button onPress={onContinue} color={"purple"} className='my-4 w-2/3'> <Button
onPress={() => {
goToNextItem({ isAutoPlay: false, resetWatchCount: true });
}}
color={"purple"}
className='my-4 w-2/3'
>
{t("player.continue_watching")} {t("player.continue_watching")}
</Button> </Button>

View File

@@ -15,15 +15,17 @@ import Animated, {
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay"; import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
import useRouter from "@/hooks/useAppRouter";
import { useCreditSkipper } from "@/hooks/useCreditSkipper"; import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation";
import { useTrickplay } from "@/hooks/useTrickplay"; import { useTrickplay } from "@/hooks/useTrickplay";
import type { TechnicalInfo } from "@/modules/mpv-player"; import type { TechnicalInfo } from "@/modules/mpv-player";
import { DownloadedItem } from "@/providers/Downloads/types"; import { DownloadedItem } from "@/providers/Downloads/types";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { ticksToMs } from "@/utils/time"; import { ticksToMs } from "@/utils/time";
import { BottomControls } from "./BottomControls"; import { BottomControls } from "./BottomControls";
import { CenterControls } from "./CenterControls"; import { CenterControls } from "./CenterControls";
@@ -102,7 +104,9 @@ export const Controls: FC<Props> = ({
transcodeReasons, transcodeReasons,
}) => { }) => {
const offline = useOfflineMode(); const offline = useOfflineMode();
const { settings } = useSettings(); const { settings, updateSettings } = useSettings();
const router = useRouter();
const lightHapticFeedback = useHaptic("light");
const [episodeView, setEpisodeView] = useState(false); const [episodeView, setEpisodeView] = useState(false);
const [showAudioSlider, setShowAudioSlider] = useState(false); const [showAudioSlider, setShowAudioSlider] = useState(false);
@@ -334,27 +338,130 @@ export const Controls: FC<Props> = ({
maxMs, maxMs,
); );
/* const goToItemCommon = useCallback(
* Single source of truth for next / previous / picker / autoplay (item: BaseItemDto) => {
* navigation. Handles SyncPlay dispatch, autoplay count gating, if (!item || !settings) {
* platform-appropriate local navigation, and offline param injection. return;
*/ }
const { lightHapticFeedback();
goToNextItem: handleNextEpisodeManual, const previousIndexes = {
goToPreviousItem: handlePreviousItem, subtitleIndex: subtitleIndex
goToItem: handleGoToItem, ? Number.parseInt(subtitleIndex, 10)
handleAutoPlayNext: handleNextEpisodeAutoPlay, : undefined,
handleContinueWatching, audioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined,
} = usePlayerItemNavigation({ };
nextItem,
previousItem, const {
mediaSource, mediaSource: newMediaSource,
currentAudioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined, audioIndex: defaultAudioIndex,
currentSubtitleIndex: subtitleIndex subtitleIndex: defaultSubtitleIndex,
? Number.parseInt(subtitleIndex, 10) } = getDefaultPlaySettings(
: undefined, item,
bitrateValue: bitrateValue ? Number.parseInt(bitrateValue, 10) : undefined, settings,
}); {
indexes: previousIndexes,
source: mediaSource ?? undefined,
},
{ applyLanguagePreferences: true },
);
// Use setParams instead of replace to avoid unmounting/remounting the player,
// which would create a new MPV native view and crash with "mp_initialize already initialized".
router.setParams({
...(offline && { offline: "true" }),
itemId: item.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString(),
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
});
},
[
settings,
subtitleIndex,
audioIndex,
mediaSource,
bitrateValue,
router,
offline,
],
);
const goToPreviousItem = useCallback(() => {
if (!previousItem) {
return;
}
goToItemCommon(previousItem);
}, [previousItem, goToItemCommon]);
const goToNextItem = useCallback(
({
isAutoPlay,
resetWatchCount,
}: {
isAutoPlay?: boolean;
resetWatchCount?: boolean;
}) => {
if (!nextItem) {
return;
}
if (!isAutoPlay) {
// if we are not autoplaying, we won't update anything, we just go to the next item
goToItemCommon(nextItem);
if (resetWatchCount) {
updateSettings({
autoPlayEpisodeCount: 0,
});
}
return;
}
// Skip autoplay logic if maxAutoPlayEpisodeCount is -1
if (settings.maxAutoPlayEpisodeCount.value === -1) {
goToItemCommon(nextItem);
return;
}
if (
settings.autoPlayEpisodeCount + 1 <
settings.maxAutoPlayEpisodeCount.value
) {
goToItemCommon(nextItem);
}
// Check if the autoPlayEpisodeCount is less than maxAutoPlayEpisodeCount for the autoPlay
if (
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value
) {
// update the autoPlayEpisodeCount in settings
updateSettings({
autoPlayEpisodeCount: settings.autoPlayEpisodeCount + 1,
});
}
},
[nextItem, goToItemCommon],
);
// Add a memoized handler for autoplay next episode
const handleNextEpisodeAutoPlay = useCallback(() => {
goToNextItem({ isAutoPlay: true });
}, [goToNextItem]);
// Add a memoized handler for manual next episode
const handleNextEpisodeManual = useCallback(() => {
goToNextItem({ isAutoPlay: false });
}, [goToNextItem]);
// Add a memoized handler for ContinueWatchingOverlay
const handleContinueWatching = useCallback(
(options: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => {
goToNextItem(options);
},
[goToNextItem],
);
const hideControls = useCallback(() => { const hideControls = useCallback(() => {
setShowControls(false); setShowControls(false);
@@ -383,7 +490,7 @@ export const Controls: FC<Props> = ({
<EpisodeList <EpisodeList
item={item} item={item}
close={() => setEpisodeView(false)} close={() => setEpisodeView(false)}
goToItem={handleGoToItem} goToItem={goToItemCommon}
/> />
) : ( ) : (
<> <>
@@ -417,8 +524,8 @@ export const Controls: FC<Props> = ({
mediaSource={mediaSource} mediaSource={mediaSource}
startPictureInPicture={startPictureInPicture} startPictureInPicture={startPictureInPicture}
switchOnEpisodeMode={switchOnEpisodeMode} switchOnEpisodeMode={switchOnEpisodeMode}
goToPreviousItem={handlePreviousItem} goToPreviousItem={goToPreviousItem}
goToNextItem={handleNextEpisodeManual} goToNextItem={goToNextItem}
previousItem={previousItem} previousItem={previousItem}
nextItem={nextItem} nextItem={nextItem}
aspectRatio={aspectRatio} aspectRatio={aspectRatio}
@@ -490,7 +597,7 @@ export const Controls: FC<Props> = ({
</> </>
)} )}
{settings.maxAutoPlayEpisodeCount.value !== -1 && ( {settings.maxAutoPlayEpisodeCount.value !== -1 && (
<ContinueWatchingOverlay onContinue={handleContinueWatching} /> <ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
)} )}
</View> </View>
); );

View File

@@ -24,7 +24,7 @@ interface HeaderControlsProps {
startPictureInPicture?: () => Promise<void>; startPictureInPicture?: () => Promise<void>;
switchOnEpisodeMode: () => void; switchOnEpisodeMode: () => void;
goToPreviousItem: () => void; goToPreviousItem: () => void;
goToNextItem: () => void; goToNextItem: (options: { isAutoPlay?: boolean }) => void;
previousItem?: BaseItemDto | null; previousItem?: BaseItemDto | null;
nextItem?: BaseItemDto | null; nextItem?: BaseItemDto | null;
aspectRatio?: AspectRatio; aspectRatio?: AspectRatio;
@@ -172,7 +172,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
)} )}
{nextItem && ( {nextItem && (
<TouchableOpacity <TouchableOpacity
onPress={() => goToNextItem()} onPress={() => goToNextItem({ isAutoPlay: false })}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
> >
<Ionicons <Ionicons

View File

@@ -1,39 +0,0 @@
import type { DefaultLanguageOption } from "@/utils/atoms/settings";
export const LANGUAGES: DefaultLanguageOption[] = [
{ label: "English", value: "eng" },
{ label: "Spanish", value: "spa" },
{ label: "Chinese (Mandarin)", value: "cmn" },
{ label: "Hindi", value: "hin" },
{ label: "Arabic", value: "ara" },
{ label: "French", value: "fra" },
{ label: "Russian", value: "rus" },
{ label: "Portuguese", value: "por" },
{ label: "Japanese", value: "jpn" },
{ label: "German", value: "deu" },
{ label: "Italian", value: "ita" },
{ label: "Korean", value: "kor" },
{ label: "Turkish", value: "tur" },
{ label: "Dutch", value: "nld" },
{ label: "Polish", value: "pol" },
{ label: "Vietnamese", value: "vie" },
{ label: "Thai", value: "tha" },
{ label: "Indonesian", value: "ind" },
{ label: "Greek", value: "ell" },
{ label: "Swedish", value: "swe" },
{ label: "Danish", value: "dan" },
{ label: "Norwegian", value: "nor" },
{ label: "Finnish", value: "fin" },
{ label: "Czech", value: "ces" },
{ label: "Hungarian", value: "hun" },
{ label: "Romanian", value: "ron" },
{ label: "Ukrainian", value: "ukr" },
{ label: "Hebrew", value: "heb" },
{ label: "Bengali", value: "ben" },
{ label: "Punjabi", value: "pan" },
{ label: "Tagalog", value: "tgl" },
{ label: "Swahili", value: "swa" },
{ label: "Malay", value: "msa" },
{ label: "Persian", value: "fas" },
{ label: "Urdu", value: "urd" },
];

View File

@@ -97,6 +97,14 @@
"credentialsSource": "local", "credentialsSource": "local",
"config": "ios-production.yml" "config": "ios-production.yml"
} }
},
"ci": {
"extends": "production",
"autoIncrement": false
},
"ci_tv": {
"extends": "production_tv",
"autoIncrement": false
} }
}, },
"submit": { "submit": {

View File

@@ -1,37 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
import { useSharedValue } from "react-native-reanimated";
export const useControlsVisibility = (timeout = 3000) => {
const opacity = useSharedValue(1);
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const showControls = useCallback(() => {
opacity.value = 1;
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
hideControlsTimerRef.current = setTimeout(() => {
opacity.value = 0;
}, timeout);
}, [timeout]);
const hideControls = useCallback(() => {
opacity.value = 0;
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
}, []);
useEffect(() => {
return () => {
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
};
}, []);
return { opacity, showControls, hideControls };
};

View File

@@ -1,35 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useCallback } from "react";
import useRouter from "@/hooks/useAppRouter";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log";
export const useDownloadedFileOpener = () => {
const router = useRouter();
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
const openFile = useCallback(
async (item: BaseItemDto) => {
if (!item.Id) {
writeToLog("ERROR", "Attempted to open a file without an ID.");
console.error("Attempted to open a file without an ID.");
return;
}
const queryParams = new URLSearchParams({
itemId: item.Id,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
try {
router.push(`/player/direct-player?${queryParams.toString()}`);
} catch (error) {
writeToLog("ERROR", "Error opening file", error);
console.error("Error opening file:", error);
}
},
[setOfflineSettings, setPlayUrl, router],
);
return { openFile };
};

View File

@@ -1,120 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom, useAtomValue } from "jotai";
import { useEffect, useMemo } from "react";
import { Platform } from "react-native";
import type * as ImageColorsType from "react-native-image-colors";
import { apiAtom } from "@/providers/JellyfinProvider";
// Conditionally import react-native-image-colors only on non-TV platforms
const ImageColors = Platform.isTV
? null
: (require("react-native-image-colors") as typeof ImageColorsType);
import {
adjustToNearBlack,
calculateTextColor,
isCloseToBlack,
itemThemeColorAtom,
} from "@/utils/atoms/primaryColor";
import { getItemImage } from "@/utils/getItemImage";
import { storage } from "@/utils/mmkv";
/**
* Custom hook to extract and manage image colors for a given item.
*
* @param item - The BaseItemDto object representing the item.
* @param disabled - A boolean flag to disable color extraction.
*
*/
export const useImageColors = ({
item,
url,
disabled,
}: {
item?: BaseItemDto | null;
url?: string | null;
disabled?: boolean;
}) => {
const api = useAtomValue(apiAtom);
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
const isTv = Platform.isTV;
const source = useMemo(() => {
if (!api) return;
if (url) return { uri: url };
if (item)
return getItemImage({
item,
api,
variant: "Primary",
quality: 80,
width: 300,
});
return null;
}, [api, item, url]);
useEffect(() => {
if (isTv) return;
if (disabled) return;
if (source?.uri) {
const _primary = storage.getString(`${source.uri}-primary`);
const _text = storage.getString(`${source.uri}-text`);
if (_primary && _text) {
setPrimaryColor({
primary: _primary,
text: _text,
});
return;
}
// Extract colors from the image
if (!ImageColors?.getColors) return;
ImageColors.getColors(source.uri, {
fallback: "#fff",
cache: false,
})
.then((colors: ImageColorsType.ImageColorsResult) => {
let primary = "#fff";
let text = "#000";
let backup = "#fff";
// Select the appropriate color based on the platform
if (colors.platform === "android") {
primary = colors.dominant;
backup = colors.vibrant;
} else if (colors.platform === "ios") {
primary = colors.detail;
backup = colors.primary;
}
// Adjust the primary color if it's too close to black
if (primary && isCloseToBlack(primary)) {
if (backup && !isCloseToBlack(backup)) primary = backup;
primary = adjustToNearBlack(primary);
}
// Calculate the text color based on the primary color
if (primary) text = calculateTextColor(primary);
setPrimaryColor({
primary,
text,
});
// Cache the colors in storage
if (source.uri && primary) {
storage.set(`${source.uri}-primary`, primary);
storage.set(`${source.uri}-text`, text);
}
})
.catch((error: any) => {
console.error("Error getting colors", error);
});
}
}, [isTv, source?.uri, setPrimaryColor, disabled]);
if (isTv) return;
};

View File

@@ -1,22 +0,0 @@
import { useEffect } from "react";
import { useWebSocketContext } from "@/providers/WebSocketProvider";
/**
* While `active` is true, hold a keep-alive token on the global
* WebSocket so it is NOT closed when the app moves to
* background/inactive. Releases automatically when `active` flips
* false or the component unmounts.
*
* Used by the video player while in Picture-in-Picture so SyncPlay
* commands (and any other server pushes) keep flowing while the OS
* thinks the app is backgrounded.
*/
export function useKeepWebSocketAlive(active: boolean): void {
const { acquireKeepAlive } = useWebSocketContext();
useEffect(() => {
if (!active) return;
const release = acquireKeepAlive();
return release;
}, [active, acquireKeepAlive]);
}

View File

@@ -1,405 +0,0 @@
/**
* Single source of truth for *all* item-level navigation inside the
* player (next / previous / picked-from-episode-list / autoplay-next).
*
* This hook encapsulates three orthogonal concerns so callers don't
* have to:
*
* 1. **SyncPlay** — when a group is active, every advance/rewind
* dispatches through `Controller`. `SyncPlayProvider` handles the
* resulting `localPlay` / `localSetCurrentPlaylistItem` events and
* navigates the local screen.
* 2. **Autoplay gating** — `maxAutoPlayEpisodeCount` limits how many
* episodes auto-play before stopping. Manual presses bypass this.
* SyncPlay bypasses it too (the server drives the queue).
* 3. **Platform navigation** — mobile uses `router.setParams` so the
* player view stays mounted (avoids a full re-mount + bitrate /
* stream re-pick cycle). TV uses `router.replace` because MPV's
* native view can't be re-initialized in place. Offline state is
* preserved automatically by `useAppRouter`.
*/
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform } from "react-native";
import useAppRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import { getDownloadedItemById } from "@/providers/Downloads";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSyncPlay } from "@/providers/SyncPlay";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
interface UsePlayerItemNavigationParams {
/**
* The adjacent item that "next" should target (from `usePlaybackManager`).
* Only needed by callers that use the in-session nav methods.
*/
nextItem?: BaseItemDto | null;
/** The adjacent item that "previous" should target. */
previousItem?: BaseItemDto | null;
/** The active media source for the *current* item; used to seed track defaults. */
mediaSource?: MediaSourceInfo | null;
/** Live audio track index (may differ from the URL param after the user changed tracks). */
currentAudioIndex?: number;
/** Live subtitle track index. */
currentSubtitleIndex?: number;
/** Currently-active bitrate cap. */
bitrateValue?: number;
/**
* Optional guard for "we're already stopping the player". TV passes
* `isPlaybackStopped` here to drop spurious next-item dispatches that
* fire during teardown.
*/
isDisabled?: boolean;
}
/** Options for `playItem` — the entry-point method used by PlayButton et al. */
export interface PlayItemOptions {
audioIndex?: number;
subtitleIndex?: number;
mediaSourceId?: string;
bitrateValue?: number;
/** Defaults to `item.UserData?.PlaybackPositionTicks`. */
playbackPosition?: number;
/**
* Force local-file playback even outside the offline UI context, and
* skip SyncPlay broadcasting. Used when the user explicitly picks the
* downloaded copy from a "play downloaded?" prompt.
*/
forceOffline?: boolean;
}
export interface PlayerItemNavigation {
/** SyncPlay-aware previous. No-op when there's no previous item. */
goToPreviousItem: () => void;
/**
* Manual next (e.g. user tapped the skip-forward / next button).
* Autoplay gating is bypassed.
*/
goToNextItem: () => void;
/** Jump to an arbitrary item (episode picker). */
goToItem: (item: BaseItemDto) => void;
/**
* Autoplay next (e.g. "Up Next" overlay countdown completed). Respects
* `maxAutoPlayEpisodeCount`. Bypassed when SyncPlay is active.
*/
handleAutoPlayNext: () => void;
/**
* Helper for the "Keep Watching" overlay button — advances and resets
* the auto-play counter so the next stretch of episodes can autoplay.
*/
handleContinueWatching: () => void;
/**
* Entry-point: start playback of an item from outside the player
* (PlayButton, Continue Watching, episode picker on the item page).
* SyncPlay-aware. Resets the autoplay counter.
*/
playItem: (item: BaseItemDto, opts?: PlayItemOptions) => Promise<void>;
}
export function usePlayerItemNavigation(
params: UsePlayerItemNavigationParams = {},
): PlayerItemNavigation {
const {
nextItem,
previousItem,
mediaSource,
currentAudioIndex,
currentSubtitleIndex,
bitrateValue,
isDisabled,
} = params;
const router = useAppRouter();
const { t } = useTranslation();
const { settings, updateSettings } = useSettings();
const { isEnabled: isSyncPlayEnabled, controller: syncPlayController } =
useSyncPlay();
const lightHapticFeedback = useHaptic("light");
// Note: "offline mode" is a *UI context* flag (set by pages entered from
// the downloads tab), not a network-connectivity status. A user may be
// in offline mode with perfect internet, watching a downloaded copy.
const inOfflineContext = useOfflineMode();
/*
* Compute the destination URL params for a given target item, using
* the live selection state (which may differ from the URL params the
* episode started with — the user may have switched tracks mid-play).
*/
const buildNavigationParams = useCallback(
(target: BaseItemDto) => {
if (!settings) return null;
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(
target,
settings,
{
indexes: {
subtitleIndex: currentSubtitleIndex,
audioIndex: currentAudioIndex,
},
source: mediaSource ?? undefined,
},
{ applyLanguagePreferences: true },
);
return {
itemId: target.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition:
target.UserData?.PlaybackPositionTicks?.toString() ?? "",
};
},
[
settings,
currentSubtitleIndex,
currentAudioIndex,
mediaSource,
bitrateValue,
],
);
/*
* Stamp the `offline` URL param onto a params object based on whether
* the target item is actually downloaded.
*
* - Online (no offline UI context) → pass through unchanged; the
* `offline` key is absent so `useAppRouter` doesn't touch it.
* - Offline context, target IS downloaded → `offline: "true"` (play
* the local copy).
* - Offline context, target is NOT downloaded → `offline: ""` (force
* online streaming; the key's presence blocks `useAppRouter` from
* auto-injecting `"true"`, and direct-player only treats the value
* `"true"` as offline).
*
* That last case is the important one: a user can be in the offline
* UI context with perfect internet (e.g. navigated in from downloads)
* and pick an episode they never downloaded. Without this the player
* would hang waiting for a local file that doesn't exist.
*/
const withOfflineParam = useCallback(
(params: Record<string, string>, target: BaseItemDto) => {
if (!inOfflineContext) return params;
const isDownloaded = !!(target.Id && getDownloadedItemById(target.Id));
return { ...params, offline: isDownloaded ? "true" : "" };
},
[inOfflineContext],
);
/*
* Platform-appropriate local navigation. Mobile keeps the same player
* view mounted via setParams; TV swaps the whole route via replace.
*/
const localNavigate = useCallback(
(target: BaseItemDto) => {
if (isDisabled) return;
const navParams = buildNavigationParams(target);
if (!navParams) return;
lightHapticFeedback();
const finalParams = withOfflineParam(navParams, target);
if (Platform.isTV) {
const queryString = new URLSearchParams(finalParams).toString();
router.replace(`/player/direct-player?${queryString}`);
} else {
router.setParams(finalParams);
}
},
[
isDisabled,
buildNavigationParams,
router,
lightHapticFeedback,
withOfflineParam,
],
);
const goToPreviousItem = useCallback(() => {
if (isSyncPlayEnabled && syncPlayController) {
syncPlayController.previousItem();
return;
}
if (!previousItem) return;
localNavigate(previousItem);
}, [isSyncPlayEnabled, syncPlayController, previousItem, localNavigate]);
const goToNextItem = useCallback(() => {
if (isSyncPlayEnabled && syncPlayController) {
syncPlayController.nextItem();
return;
}
if (!nextItem) return;
localNavigate(nextItem);
}, [isSyncPlayEnabled, syncPlayController, nextItem, localNavigate]);
const goToItem = useCallback(
(target: BaseItemDto) => {
if (isSyncPlayEnabled && syncPlayController && target.Id) {
syncPlayController.goToItem(target);
return;
}
localNavigate(target);
},
[isSyncPlayEnabled, syncPlayController, localNavigate],
);
const handleAutoPlayNext = useCallback(() => {
// SyncPlay always advances unconditionally — the server is the source
// of truth for queue progression and per-client gating would desync us.
if (isSyncPlayEnabled && syncPlayController) {
syncPlayController.nextItem();
return;
}
if (!nextItem) return;
const maxCount = settings?.maxAutoPlayEpisodeCount.value ?? 0;
const currentCount = settings?.autoPlayEpisodeCount ?? 0;
// -1 means "no limit"
if (maxCount === -1) {
localNavigate(nextItem);
return;
}
if (currentCount + 1 < maxCount) {
localNavigate(nextItem);
}
if (currentCount < maxCount) {
updateSettings({ autoPlayEpisodeCount: currentCount + 1 });
}
}, [
isSyncPlayEnabled,
syncPlayController,
nextItem,
settings,
updateSettings,
localNavigate,
]);
const handleContinueWatching = useCallback(() => {
if (isSyncPlayEnabled && syncPlayController) {
syncPlayController.nextItem();
return;
}
if (!nextItem) return;
updateSettings({ autoPlayEpisodeCount: 0 });
localNavigate(nextItem);
}, [
isSyncPlayEnabled,
syncPlayController,
nextItem,
updateSettings,
localNavigate,
]);
/*
* Entry-point: start playback of an item from outside the player.
*
* Used by PlayButton, Continue Watching cards, episode pickers on the
* item page, etc. Unlike the in-session methods, this always uses
* `router.push` (we're entering the player, not navigating within it)
* and runs on both mobile and TV with the same shape.
*
* SyncPlay: when in a group and the user *didn't* explicitly request
* local playback, we route through `controller.play()` so every group
* member gets the same `PlayQueue: NewPlaylist` update and navigates
* together. Errors surface as an Alert and abort the local navigation
* (matches `PlayButton`'s previous behavior).
*/
const playItem = useCallback(
async (item: BaseItemDto, opts: PlayItemOptions = {}) => {
if (!item.Id) return;
lightHapticFeedback();
const startPositionTicks =
opts.playbackPosition ?? item.UserData?.PlaybackPositionTicks ?? 0;
// Fresh playback start — reset the autoplay budget so the next
// stretch of episodes can autoplay.
if (settings && settings.maxAutoPlayEpisodeCount.value !== -1) {
updateSettings({ autoPlayEpisodeCount: 0 });
}
// SyncPlay: broadcast to the group instead of navigating locally.
// Skipped when the user explicitly picked the downloaded copy — a
// local file can't be part of a synced session.
if (!opts.forceOffline && isSyncPlayEnabled && syncPlayController) {
try {
await syncPlayController.play({
items: [item],
ids: [item.Id],
startPositionTicks,
});
} catch (error) {
console.error("SyncPlay: failed to start group playback", error);
Alert.alert(
t("player.client_error"),
t("syncplay.failed_to_start", {
defaultValue: "Failed to start SyncPlay group playback",
}),
);
}
return;
}
// Build a string-record so we can run it through `withOfflineParam`
// and `URLSearchParams` uniformly.
const baseParams: Record<string, string> = {
itemId: item.Id,
playbackPosition: String(startPositionTicks),
};
if (opts.audioIndex !== undefined) {
baseParams.audioIndex = String(opts.audioIndex);
}
if (opts.subtitleIndex !== undefined) {
baseParams.subtitleIndex = String(opts.subtitleIndex);
}
if (opts.mediaSourceId) {
baseParams.mediaSourceId = opts.mediaSourceId;
}
if (opts.bitrateValue !== undefined) {
baseParams.bitrateValue = String(opts.bitrateValue);
}
const finalParams = opts.forceOffline
? { ...baseParams, offline: "true" }
: withOfflineParam(baseParams, item);
const queryString = new URLSearchParams(finalParams).toString();
router.push(`/player/direct-player?${queryString}`);
},
[
lightHapticFeedback,
settings,
updateSettings,
isSyncPlayEnabled,
syncPlayController,
withOfflineParam,
router,
t,
],
);
return {
goToPreviousItem,
goToNextItem,
goToItem,
handleAutoPlayNext,
handleContinueWatching,
playItem,
};
}
export default usePlayerItemNavigation;

View File

@@ -2,7 +2,6 @@ import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert } from "react-native"; import { Alert } from "react-native";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
import { useWebSocketContext } from "@/providers/WebSocketProvider"; import { useWebSocketContext } from "@/providers/WebSocketProvider";
interface UseWebSocketProps { interface UseWebSocketProps {
@@ -81,9 +80,9 @@ export const useWebSocket = ({
playTrailers, playTrailers,
}: UseWebSocketProps) => { }: UseWebSocketProps) => {
const router = useRouter(); const router = useRouter();
const { lastMessage, clearLastMessage } = useWebSocketContext(); const { lastMessage } = useWebSocketContext();
const { t } = useTranslation(); const { t } = useTranslation();
const { isEnabled: isSyncPlayEnabled } = useSyncPlay(); const { clearLastMessage } = useWebSocketContext();
useEffect(() => { useEffect(() => {
if (!lastMessage) return; if (!lastMessage) return;
@@ -97,25 +96,6 @@ export const useWebSocket = ({
| Record<string, string> | Record<string, string>
| undefined; // Arguments are Dictionary<string, string> | undefined; // Arguments are Dictionary<string, string>
// Skip playback commands when SyncPlay is enabled - SyncPlay handles these
const isSyncPlayCommand =
lastMessage.MessageType === "SyncPlayCommand" ||
lastMessage.MessageType === "SyncPlayGroupUpdate";
const isPlaybackCommand = [
"PlayPause",
"Pause",
"Unpause",
"Stop",
"Seek",
"NextTrack",
"PreviousTrack",
].includes(command ?? "");
if (isSyncPlayEnabled && (isSyncPlayCommand || isPlaybackCommand)) {
console.log(`Command ~ ${command} - skipping, SyncPlay handles playback`);
return;
}
if (command === "PlayPause") { if (command === "PlayPause") {
console.log("Command ~ PlayPause"); console.log("Command ~ PlayPause");
togglePlay(); togglePlay();

View File

@@ -53,7 +53,6 @@ export function useWifiSSID(): UseWifiSSIDReturn {
const fetchSSID = useCallback(async () => { const fetchSSID = useCallback(async () => {
if (Platform.isTV) return; if (Platform.isTV) return;
const result = await getSSID(); const result = await getSSID();
console.log("[WiFi Debug] Native module SSID:", result);
setSSID(result); setSSID(result);
}, []); }, []);

View File

@@ -50,13 +50,6 @@ class MpvPlayerModule : Module() {
// No-op on Android - media session integration would require MediaSessionCompat // No-op on Android - media session integration would require MediaSessionCompat
} }
// When true, PiP play/pause/skip controls emit JS events
// instead of driving MPV directly, so the host app can route
// through SyncPlay (server -> group broadcast -> all clients).
Prop("syncPlayDelegated") { view: MpvPlayerView, delegated: Boolean ->
view.syncPlayDelegated = delegated
}
// Async function to play video // Async function to play video
AsyncFunction("play") { view: MpvPlayerView -> AsyncFunction("play") { view: MpvPlayerView ->
view.play() view.play()
@@ -205,7 +198,7 @@ class MpvPlayerModule : Module() {
} }
// Defines events that the view can send to JavaScript // Defines events that the view can send to JavaScript
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange", "onPipPlayRequest", "onPipPauseRequest", "onPipSkipRequest") Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
} }
} }
} }

View File

@@ -47,16 +47,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
val onError by EventDispatcher() val onError by EventDispatcher()
val onTracksReady by EventDispatcher() val onTracksReady by EventDispatcher()
val onPictureInPictureChange by EventDispatcher() val onPictureInPictureChange by EventDispatcher()
// SyncPlay: when `syncPlayDelegated == true`, PiP playback controls
// (play / pause / skip) emit these events instead of driving MPV
// directly, so JS can route the action through the SyncPlay
// controller (server -> group broadcast -> all clients). Default
// behavior (non-SyncPlay) is unchanged.
val onPipPlayRequest by EventDispatcher()
val onPipPauseRequest by EventDispatcher()
val onPipSkipRequest by EventDispatcher()
var syncPlayDelegated: Boolean = false
private var textureView: TextureView private var textureView: TextureView
private var renderer: MPVLayerRenderer? = null private var renderer: MPVLayerRenderer? = null
@@ -95,32 +85,14 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
pipController?.setPlayerView(textureView) pipController?.setPlayerView(textureView)
pipController?.delegate = object : PiPController.Delegate { pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() { override fun onPlay() {
if (syncPlayDelegated) {
onPipPlayRequest(mapOf<String, Any>())
return
}
play() play()
} }
override fun onPause() { override fun onPause() {
if (syncPlayDelegated) {
onPipPauseRequest(mapOf<String, Any>())
return
}
pause() pause()
} }
override fun onSeekBy(seconds: Double) { override fun onSeekBy(seconds: Double) {
if (syncPlayDelegated) {
val target = (cachedPosition + seconds).coerceAtLeast(0.0)
onPipSkipRequest(
mapOf(
"targetSeconds" to target,
"intervalSeconds" to seconds
)
)
return
}
seekBy(seconds) seekBy(seconds)
} }

View File

@@ -65,13 +65,6 @@ public class MpvPlayerModule: Module {
} }
} }
// When true, PiP play/pause/skip controls emit JS events instead
// of driving MPV directly, so the host app can route through
// SyncPlay (server -> group broadcast -> all clients).
Prop("syncPlayDelegated") { (view: MpvPlayerView, delegated: Bool) in
view.syncPlayDelegated = delegated
}
// Async function to play video // Async function to play video
AsyncFunction("play") { (view: MpvPlayerView) in AsyncFunction("play") { (view: MpvPlayerView) in
view.play() view.play()
@@ -220,7 +213,7 @@ public class MpvPlayerModule: Module {
} }
// Defines events that the view can send to JavaScript // Defines events that the view can send to JavaScript
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange", "onPipPlayRequest", "onPipPauseRequest", "onPipSkipRequest") Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
} }
} }
} }

View File

@@ -62,16 +62,6 @@ class MpvPlayerView: ExpoView {
let onError = EventDispatcher() let onError = EventDispatcher()
let onTracksReady = EventDispatcher() let onTracksReady = EventDispatcher()
let onPictureInPictureChange = EventDispatcher() let onPictureInPictureChange = EventDispatcher()
// SyncPlay: when `syncPlayDelegated == true`, PiP playback controls
// (play / pause / skip) emit these events instead of driving MPV
// directly, so JS can route the action through the SyncPlay
// controller (server group broadcast all clients). Default
// behavior (non-SyncPlay) is unchanged.
let onPipPlayRequest = EventDispatcher()
let onPipPauseRequest = EventDispatcher()
let onPipSkipRequest = EventDispatcher()
var syncPlayDelegated: Bool = false
private var currentURL: URL? private var currentURL: URL?
private var cachedPosition: Double = 0 private var cachedPosition: Double = 0
@@ -681,12 +671,6 @@ extension MpvPlayerView: PiPControllerDelegate {
func pipControllerPlay(_ controller: PiPController) { func pipControllerPlay(_ controller: PiPController) {
print("PiP play requested") print("PiP play requested")
if syncPlayDelegated {
// Let JS route through SyncPlay. We deliberately do NOT touch
// MPV here; the WS command coming back will drive playback.
onPipPlayRequest([:])
return
}
intendedPlayState = true intendedPlayState = true
renderer?.play() renderer?.play()
pipController?.setPlaybackRate(1.0) pipController?.setPlaybackRate(1.0)
@@ -694,10 +678,6 @@ extension MpvPlayerView: PiPControllerDelegate {
func pipControllerPause(_ controller: PiPController) { func pipControllerPause(_ controller: PiPController) {
print("PiP pause requested") print("PiP pause requested")
if syncPlayDelegated {
onPipPauseRequest([:])
return
}
intendedPlayState = false intendedPlayState = false
renderer?.pausePlayback() renderer?.pausePlayback()
pipController?.setPlaybackRate(0.0) pipController?.setPlaybackRate(0.0)
@@ -707,16 +687,6 @@ extension MpvPlayerView: PiPControllerDelegate {
let seconds = CMTimeGetSeconds(interval) let seconds = CMTimeGetSeconds(interval)
print("PiP skip by interval: \(seconds)") print("PiP skip by interval: \(seconds)")
let target = max(0, cachedPosition + seconds) let target = max(0, cachedPosition + seconds)
if syncPlayDelegated {
// `targetSeconds` lets JS convert to ticks and call
// syncPlayController.seek(). `intervalSeconds` is also sent
// for telemetry / debug.
onPipSkipRequest([
"targetSeconds": target,
"intervalSeconds": seconds
])
return
}
seekTo(position: target) seekTo(position: target)
} }

View File

@@ -29,21 +29,6 @@ export type OnPictureInPictureChangePayload = {
isActive: boolean; isActive: boolean;
}; };
/**
* Emitted when the user taps a PiP playback control while the view
* was rendered with `syncPlayDelegated`. The host app should route
* the action through the SyncPlay controller instead of acting
* locally.
*/
export type OnPipPlayRequestPayload = Record<string, never>;
export type OnPipPauseRequestPayload = Record<string, never>;
export type OnPipSkipRequestPayload = {
/** Absolute target position the user wants to seek to, in seconds. */
targetSeconds: number;
/** Skip interval requested by the OS (signed seconds). Debug only. */
intervalSeconds: number;
};
export type NowPlayingMetadata = { export type NowPlayingMetadata = {
title?: string; title?: string;
artist?: string; artist?: string;
@@ -99,18 +84,6 @@ export type MpvPlayerViewProps = {
onPictureInPictureChange?: (event: { onPictureInPictureChange?: (event: {
nativeEvent: OnPictureInPictureChangePayload; nativeEvent: OnPictureInPictureChangePayload;
}) => void; }) => void;
/**
* When true, PiP play/pause/skip controls emit the corresponding
* `onPipPlayRequest` / `onPipPauseRequest` / `onPipSkipRequest`
* events instead of driving MPV directly. Used to route PiP control
* actions through SyncPlay.
*/
syncPlayDelegated?: boolean;
onPipPlayRequest?: (event: { nativeEvent: OnPipPlayRequestPayload }) => void;
onPipPauseRequest?: (event: {
nativeEvent: OnPipPauseRequestPayload;
}) => void;
onPipSkipRequest?: (event: { nativeEvent: OnPipSkipRequestPayload }) => void;
}; };
export interface MpvPlayerViewRef { export interface MpvPlayerViewRef {

View File

@@ -1,10 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application> <application>
<receiver <receiver android:name=".TvRecommendationsReceiver" android:exported="true">
android:name=".TvRecommendationsReceiver"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" /> <action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" />
</intent-filter> </intent-filter>
</receiver> </receiver>
</application> </application>

View File

@@ -16,12 +16,13 @@ import androidx.tvprovider.media.tv.PreviewProgram
import androidx.tvprovider.media.tv.TvContractCompat import androidx.tvprovider.media.tv.TvContractCompat
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.security.MessageDigest
internal object TvRecommendationsPublisher { internal object TvRecommendationsPublisher {
private const val TAG = "TvRecommendations" private const val TAG = "TvRecommendations"
private const val PREFS_NAME = "StreamyfinTvRecommendations" private const val PREFS_NAME = "StreamyfinTvRecommendations"
private const val KEY_PAYLOAD = "payload" private const val KEY_PAYLOAD = "payload"
private const val KEY_CHANNEL_ID = "channelId" private const val KEY_CHANNEL_ID_PREFIX = "channelId_"
private const val KEY_PROGRAM_IDS = "programIds" private const val KEY_PROGRAM_IDS = "programIds"
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up" private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
@@ -61,31 +62,61 @@ internal object TvRecommendationsPublisher {
fun clear(context: Context): Boolean { fun clear(context: Context): Boolean {
val prefs = preferences(context) val prefs = preferences(context)
val channelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val programIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
if (programIds != null) { // KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" }
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
if (allProgramIds != null) {
var deletedPrograms = 0 var deletedPrograms = 0
val keys = programIds.keys() val channelKeys = allProgramIds.keys()
while (keys.hasNext()) { while (channelKeys.hasNext()) {
val key = keys.next() val channelIdStr = channelKeys.next()
val programId = programIds.optLong(key, -1L) val programIdsJson = allProgramIds.optString(channelIdStr)
if (programId > 0L) { if (programIdsJson.isBlank()) continue
contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId), try {
null, val programIds = JSONObject(programIdsJson)
null val keys = programIds.keys()
) while (keys.hasNext()) {
deletedPrograms += 1 val providerId = keys.next()
val programId = programIds.optLong(providerId, -1L)
if (programId > 0L) {
deletePreviewProgram(contentResolver, programId)
deletedPrograms += 1
}
}
} catch (e: Exception) {
Log.w(TAG, "clear(): failed to parse programIds for channel $channelIdStr", e)
} }
// Notify the channel
val channelId = channelIdStr.toLongOrNull() ?: -1L
if (channelId > 0L) {
try {
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
} catch (e: SecurityException) {
Log.w(TAG, "clear(): lost provider permission, cannot notify channel $channelId", e)
}
}
// Remove per-channel pref
prefs.edit().remove("programIds_$channelIdStr").apply()
} }
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)") Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
} }
if (channelId > 0L) { // Also handle legacy format (flat { providerId: programId }) for migration
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null) val legacyProgramIds = prefs.getString("legacy_" + KEY_PROGRAM_IDS, null)?.let(::JSONObject)
Log.d(TAG, "clear(): notified channel $channelId") if (legacyProgramIds != null) {
val keys = legacyProgramIds.keys()
while (keys.hasNext()) {
val key = keys.next()
val programId = legacyProgramIds.optLong(key, -1L)
if (programId > 0L) {
deletePreviewProgram(contentResolver, programId)
}
}
prefs.edit().remove("legacy_" + KEY_PROGRAM_IDS).apply()
} }
prefs.edit() prefs.edit()
@@ -96,128 +127,274 @@ internal object TvRecommendationsPublisher {
return true return true
} }
/**
* Delete a single preview program from the TvProvider.
* Called by clear(), synchronize() (stale programs), and TvRecommendationsReceiver (user removed).
*/
fun deletePreviewProgram(context: Context, programId: Long) {
try {
context.contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
Log.d(TAG, "deletePreviewProgram(): deleted programId=$programId")
// Also remove from stored programIds prefs
removeProgramFromPrefs(context, programId)
} catch (e: SecurityException) {
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
}
}
private fun deletePreviewProgram(contentResolver: android.content.ContentResolver, programId: Long) {
try {
contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
} catch (e: SecurityException) {
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
}
}
private fun removeProgramFromPrefs(context: Context, programId: Long) {
val prefs = preferences(context)
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
try {
val channelMap = JSONObject(programIdsJson)
val channelKeys = channelMap.keys()
while (channelKeys.hasNext()) {
val channelId = channelKeys.next()
val inner = channelMap.optJSONObject(channelId) ?: continue
val providerKeys = inner.keys()
while (providerKeys.hasNext()) {
val providerId = providerKeys.next()
if (inner.optLong(providerId, -1L) == programId) {
inner.remove(providerId)
if (inner.length() == 0) {
channelMap.remove(channelId)
}
break
}
}
}
prefs.edit().putString(KEY_PROGRAM_IDS, channelMap.toString()).apply()
} catch (e: Exception) {
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
}
}
private fun synchronize(context: Context, payload: JSONObject): Boolean { private fun synchronize(context: Context, payload: JSONObject): Boolean {
val sections = payload.optJSONArray("sections") ?: JSONArray() val sections = payload.optJSONArray("sections") ?: JSONArray()
val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null if (sections.length() == 0) {
val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME Log.w(TAG, "synchronize(): no sections in payload")
val items = firstSection?.optJSONArray("items") ?: JSONArray()
Log.d(
TAG,
"synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)"
)
val channelId = getOrCreateChannel(context, sectionTitle)
if (channelId <= 0L) {
Log.w(TAG, "synchronize(): failed to get or create preview channel")
return false return false
} }
Log.d(TAG, "synchronize(): publishing into channelId=$channelId") val prefs = preferences(context)
val allNextProgramIds = JSONObject()
var totalActive = 0
var totalDeleted = 0
val previousProgramIds = preferences(context) for (sectionIndex in 0 until sections.length()) {
.getString(KEY_PROGRAM_IDS, null) val section = sections.optJSONObject(sectionIndex) ?: continue
?.let(::JSONObject) val sectionTitle = section.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
?: JSONObject() val items = section.optJSONArray("items") ?: JSONArray()
val nextProgramIds = JSONObject()
val activeProviderIds = mutableSetOf<String>()
for (index in 0 until items.length()) { Log.d(
val item = items.optJSONObject(index) ?: continue TAG,
val providerId = item.optString("id") "synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
if (providerId.isBlank()) continue
val programId = upsertPreviewProgram(
context = context,
channelId = channelId,
item = item,
previousProgramId = previousProgramIds.optLong(providerId, -1L),
weight = index
) )
if (programId > 0L) { val channelId = getOrCreateChannel(context, sectionTitle)
activeProviderIds += providerId if (channelId <= 0L) {
nextProgramIds.put(providerId, programId) Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId") continue
} }
}
var deletedPrograms = 0 // Per Android docs: check channel.isBrowsable() and request if needed.
val previousKeys = previousProgramIds.keys() if (!isChannelBrowsable(context, channelId)) {
while (previousKeys.hasNext()) { Log.d(TAG, "synchronize(): channel $channelId not browsable, requesting browsable")
val providerId = previousKeys.next() TvContractCompat.requestChannelBrowsable(context, channelId)
if (activeProviderIds.contains(providerId)) continue }
val programId = previousProgramIds.optLong(providerId, -1L) val prefKey = "programIds_$channelId"
if (programId > 0L) { val previousProgramIds = prefs.getString(prefKey, null)
context.contentResolver.delete( ?.let(::JSONObject)
TvContractCompat.buildPreviewProgramUri(programId), ?: JSONObject()
null, val nextProgramIds = JSONObject()
null val activeProviderIds = mutableSetOf<String>()
for (index in 0 until items.length()) {
val item = items.optJSONObject(index) ?: continue
val providerId = item.optString("id")
if (providerId.isBlank()) continue
val programId = upsertPreviewProgram(
context = context,
channelId = channelId,
item = item,
previousProgramId = previousProgramIds.optLong(providerId, -1L),
weight = index
) )
deletedPrograms += 1
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId") if (programId > 0L) {
activeProviderIds += providerId
nextProgramIds.put(providerId, programId)
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
}
} }
var deletedPrograms = 0
val previousKeys = previousProgramIds.keys()
while (previousKeys.hasNext()) {
val providerId = previousKeys.next()
if (activeProviderIds.contains(providerId)) continue
val programId = previousProgramIds.optLong(providerId, -1L)
if (programId > 0L) {
deletePreviewProgram(context, programId)
deletedPrograms += 1
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
}
}
allNextProgramIds.put(channelId.toString(), nextProgramIds.toString())
prefs.edit().putString(prefKey, nextProgramIds.toString()).apply()
totalActive += activeProviderIds.size
totalDeleted += deletedPrograms
logProviderState(context, channelId)
} }
preferences(context) // Store all channel program IDs for clear() to use
.edit() prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
.putLong(KEY_CHANNEL_ID, channelId)
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString())
.apply()
logProviderState(context, channelId)
Log.d( Log.d(
TAG, TAG,
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)" "synchronize(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted stale program(s)"
) )
return true return true
} }
/**
* Query provider to check if a channel is browsable.
* Per Android docs: "check channel.isBrowsable() before updating programs."
*/
private fun isChannelBrowsable(context: Context, channelId: Long): Boolean {
return try {
context.contentResolver.query(
TvContractCompat.buildChannelUri(channelId),
null,
null,
null,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE)
if (browsableIndex >= 0) cursor.getInt(browsableIndex) == 1 else true
} else {
false
}
} ?: false
} catch (e: SecurityException) {
Log.w(TAG, "isChannelBrowsable(): lost provider permission for channelId=$channelId", e)
true // Assume browsable if we can't check, to avoid blocking updates
}
}
/**
* Query provider to verify a channel actually exists.
* Prevents the channel-delete-recreate bug: if update() returns 0 rows
* we must first check whether the channel was deleted by the system
* or if the update simply failed for another reason.
*/
private fun channelExistsInProvider(context: Context, channelId: Long): Boolean {
return try {
context.contentResolver.query(
TvContractCompat.buildChannelUri(channelId),
null,
null,
null,
null
)?.use { cursor ->
cursor.moveToFirst()
} ?: false
} catch (e: SecurityException) {
Log.w(TAG, "channelExistsInProvider(): lost provider permission for channelId=$channelId", e)
false
}
}
private fun getOrCreateChannel(context: Context, displayName: String): Long { private fun getOrCreateChannel(context: Context, displayName: String): Long {
val prefs = preferences(context) val prefs = preferences(context)
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L) val channelKey = getChannelKey(displayName)
val existingChannelId = prefs.getLong(channelKey, -1L)
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
if (existingChannelId > 0L) { if (existingChannelId > 0L) {
val updated = Channel.Builder() // Query provider first to verify channel actually exists (prevents recreate bug)
.setType(TvContractCompat.Channels.TYPE_PREVIEW) val exists = channelExistsInProvider(context, existingChannelId)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
val updatedRows = contentResolver.update( if (exists) {
TvContractCompat.buildChannelUri(existingChannelId), // Channel exists — update it in place, never recreate
updated.toContentValues(), val updated = Channel.Builder()
null, .setType(TvContractCompat.Channels.TYPE_PREVIEW)
null .setDisplayName(displayName)
) .setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
if (updatedRows > 0) { try {
TvContractCompat.requestChannelBrowsable(context, existingChannelId) val updatedRows = contentResolver.update(
storeChannelLogo(context, existingChannelId) TvContractCompat.buildChannelUri(existingChannelId),
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable") updated.toContentValues(),
return existingChannelId null,
null
)
if (updatedRows > 0) {
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
storeChannelLogo(context, existingChannelId)
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
return existingChannelId
}
// Update returned 0 rows but channel exists — log and return existing ID, don't recreate
Log.e(TAG, "getOrCreateChannel(): update returned 0 for existing channelId=$existingChannelId but channel exists — not recreating")
return existingChannelId
} catch (e: SecurityException) {
Log.w(TAG, "getOrCreateChannel(): lost provider permission updating channelId=$existingChannelId", e)
return existingChannelId
}
} }
Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating") // Channel truly doesn't exist in provider — recreate
prefs.edit().remove(KEY_CHANNEL_ID).apply() Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
prefs.edit().remove(channelKey).apply()
} }
// Create a new channel
val channel = Channel.Builder() val channel = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW) .setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName) .setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://")) .setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build() .build()
val channelUri = contentResolver.insert( val channelUri = try {
TvContractCompat.Channels.CONTENT_URI, contentResolver.insert(
channel.toContentValues() TvContractCompat.Channels.CONTENT_URI,
) ?: return -1L channel.toContentValues()
)
} catch (e: SecurityException) {
Log.e(TAG, "getOrCreateChannel(): lost provider permission, cannot create channel", e)
null
} ?: return -1L
val channelId = ContentUris.parseId(channelUri) val channelId = ContentUris.parseId(channelUri)
prefs.edit().putLong(channelKey, channelId).apply()
TvContractCompat.requestChannelBrowsable(context, channelId) TvContractCompat.requestChannelBrowsable(context, channelId)
storeChannelLogo(context, channelId) storeChannelLogo(context, channelId)
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"") Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
@@ -225,6 +402,10 @@ internal object TvRecommendationsPublisher {
return channelId return channelId
} }
private fun getChannelKey(displayName: String): String {
return KEY_CHANNEL_ID_PREFIX + displayName.hashCode()
}
private fun upsertPreviewProgram( private fun upsertPreviewProgram(
context: Context, context: Context,
channelId: Long, channelId: Long,
@@ -249,42 +430,67 @@ internal object TvRecommendationsPublisher {
builder.setDescription(it) builder.setDescription(it)
} }
// Per Android docs: use unique URIs for all images to avoid stale cache
imageUrl.takeIf { it.isNotBlank() }?.let { imageUrl.takeIf { it.isNotBlank() }?.let {
val imageUri = Uri.parse(it) val uniqueImageUrl = appendCacheBuster(it)
val imageUri = Uri.parse(uniqueImageUrl)
builder.setPosterArtUri(imageUri) builder.setPosterArtUri(imageUri)
builder.setThumbnailUri(imageUri) builder.setThumbnailUri(imageUri)
} }
val contentValues = builder.build().toContentValues() val contentValues = builder.build().toContentValues()
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
if (previousProgramId > 0L) { if (previousProgramId > 0L) {
val updatedRows = contentResolver.update( try {
TvContractCompat.buildPreviewProgramUri(previousProgramId), val updatedRows = contentResolver.update(
contentValues, TvContractCompat.buildPreviewProgramUri(previousProgramId),
null, contentValues,
null null,
) null
)
if (updatedRows > 0) { if (updatedRows > 0) {
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId") Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
return previousProgramId return previousProgramId
}
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
} catch (e: SecurityException) {
Log.w(TAG, "upsertPreviewProgram(): lost provider permission for programId=$previousProgramId", e)
} }
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
} }
val insertedUri = contentResolver.insert( val insertedUri = try {
TvContractCompat.PreviewPrograms.CONTENT_URI, contentResolver.insert(
contentValues TvContractCompat.PreviewPrograms.CONTENT_URI,
) ?: return -1L contentValues
)
} catch (e: SecurityException) {
Log.w(TAG, "upsertPreviewProgram(): lost provider permission, cannot insert program", e)
null
} ?: return -1L
val programId = ContentUris.parseId(insertedUri) val programId = ContentUris.parseId(insertedUri)
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId") Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
return programId return programId
} }
/**
* Append a stable cache key derived from the image URL.
* The Jellyfin image URLs already include a `tag=` query param (etag)
* that changes whenever the image content changes, so a deterministic
* hash of the URL is sufficient — the param only changes when the URL
* (and therefore the image) actually changes, avoiding unnecessary
* re-downloads on every sync.
*/
private fun appendCacheBuster(imageUrl: String): String {
val digest = MessageDigest.getInstance("MD5").digest(imageUrl.toByteArray())
val hash = digest.joinToString("") { "%02x".format(it) }.substring(0, 16)
val separator = if (imageUrl.contains("?")) "&" else "?"
return "$imageUrl${separator}_v=$hash"
}
private fun buildIntentUri(context: Context, deepLink: String): Uri { private fun buildIntentUri(context: Context, deepLink: String): Uri {
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(deepLink) data = Uri.parse(deepLink)
@@ -306,13 +512,17 @@ internal object TvRecommendationsPublisher {
private fun storeChannelLogo(context: Context, channelId: Long) { private fun storeChannelLogo(context: Context, channelId: Long) {
val bitmap = applicationIconBitmap(context) ?: return val bitmap = applicationIconBitmap(context) ?: return
val outputStream = context.contentResolver.openOutputStream( try {
TvContractCompat.buildChannelLogoUri(channelId) val outputStream = context.contentResolver.openOutputStream(
) ?: return TvContractCompat.buildChannelLogoUri(channelId)
) ?: return
outputStream.use { stream -> outputStream.use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.flush() stream.flush()
}
} catch (e: SecurityException) {
Log.w(TAG, "storeChannelLogo(): lost provider permission for channelId=$channelId", e)
} }
} }
@@ -341,9 +551,14 @@ internal object TvRecommendationsPublisher {
return bitmap return bitmap
} }
fun getChannelId(context: Context, displayName: String = DEFAULT_CHANNEL_NAME): Long {
return preferences(context).getLong(getChannelKey(displayName), -1L)
}
private fun preferences(context: Context): SharedPreferences { private fun preferences(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
} }
private fun logProviderState(context: Context, channelId: Long) { private fun logProviderState(context: Context, channelId: Long) {
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
@@ -372,8 +587,10 @@ internal object TvRecommendationsPublisher {
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false") Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
} }
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId") } ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
} catch (error: Exception) { } catch (error: SecurityException) {
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error) Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
} } catch (error: Exception) {
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
}
} }
} }

View File

@@ -3,16 +3,24 @@ package expo.modules.tvrecommendations
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ContentUris
import android.util.Log import android.util.Log
import androidx.tvprovider.media.tv.TvContractCompat import androidx.tvprovider.media.tv.TvContractCompat
class TvRecommendationsReceiver : BroadcastReceiver() { class TvRecommendationsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) { when (intent.action) {
return TvContractCompat.ACTION_INITIALIZE_PROGRAMS -> {
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
TvRecommendationsPublisher.refreshFromCache(context)
}
"android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" -> {
val programId = intent.data?.let { ContentUris.parseId(it) } ?: -1L
if (programId > 0L) {
Log.d("TvRecommendations", "Handling PREVIEW_PROGRAM_BROWSABLE_DISABLED for programId=$programId")
TvRecommendationsPublisher.deletePreviewProgram(context, programId)
}
}
} }
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
TvRecommendationsPublisher.refreshFromCache(context)
} }
} }

View File

@@ -1,12 +1,19 @@
import { requireNativeView } from "expo"; import { requireNativeView } from "expo";
import * as React from "react"; import * as React from "react";
import type { View } from "react-native"; import type { View } from "react-native";
import { Platform } from "react-native";
import type { TvSearchViewProps } from "./TvSearchView.types"; import type { TvSearchViewProps } from "./TvSearchView.types";
// The native TvSearchModule is Apple-only (tvOS SwiftUI `.searchable`).
// On Android the component is never rendered, but we must avoid calling
// `requireNativeView` at module-scope because it would crash on import.
const NativeView: React.ComponentType< const NativeView: React.ComponentType<
TvSearchViewProps & React.RefAttributes<View> TvSearchViewProps & React.RefAttributes<View>
> = requireNativeView("TvSearchModule"); > =
Platform.OS === "ios"
? requireNativeView("TvSearchModule")
: ((() => null) as any);
/** /**
* Forwards its ref to the underlying native view so it can be used as a * Forwards its ref to the underlying native view so it can be used as a

View File

@@ -15,7 +15,6 @@ const WifiSsidModule =
*/ */
export async function getSSID(): Promise<string | null> { export async function getSSID(): Promise<string | null> {
if (!WifiSsidModule) { if (!WifiSsidModule) {
console.log("[WifiSsid] Module not available on this platform");
return null; return null;
} }

View File

@@ -22,7 +22,9 @@
"lint": "biome check --write --unsafe --max-diagnostics 1000", "lint": "biome check --write --unsafe --max-diagnostics 1000",
"format": "biome format --write .", "format": "biome format --write .",
"doctor": "expo-doctor", "doctor": "expo-doctor",
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor", "i18n:check": "bun scripts/check-i18n-keys.mjs",
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
"postinstall": "patch-package" "postinstall": "patch-package"
}, },
"dependencies": { "dependencies": {
@@ -129,7 +131,7 @@
"@types/react": "~19.2.10", "@types/react": "~19.2.10",
"@types/react-test-renderer": "19.1.0", "@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0", "cross-env": "10.1.0",
"expo-doctor": "1.19.7", "expo-doctor": "1.19.9",
"husky": "9.1.7", "husky": "9.1.7",
"lint-staged": "17.0.5", "lint-staged": "17.0.5",
"react-test-renderer": "19.2.3", "react-test-renderer": "19.2.3",

View File

@@ -142,31 +142,12 @@ export function useDownloadEventHandlers({
} else { } else {
// Transcoding - estimate from bitrate // Transcoding - estimate from bitrate
const process = processes.find((p) => p.id === processId); const process = processes.find((p) => p.id === processId);
console.log( if (process?.maxBitrate.value && process.item.RunTimeTicks) {
`[DPL] Transcoding detected, looking for process ${processId}, found:`, const { estimateDownloadSize } = require("@/utils/download");
process ? "yes" : "no", estimatedTotalBytes = estimateDownloadSize(
); process.maxBitrate.value,
if (process) { process.item.RunTimeTicks,
console.log(`[DPL] Process bitrate:`, { );
key: process.maxBitrate.key,
value: process.maxBitrate.value,
runTimeTicks: process.item.RunTimeTicks,
});
if (process.maxBitrate.value && process.item.RunTimeTicks) {
const { estimateDownloadSize } = require("@/utils/download");
estimatedTotalBytes = estimateDownloadSize(
process.maxBitrate.value,
process.item.RunTimeTicks,
);
console.log(
`[DPL] Calculated estimatedTotalBytes:`,
estimatedTotalBytes,
);
} else {
console.log(
`[DPL] Cannot estimate size - bitrate.value or RunTimeTicks missing`,
);
}
} }
} }

View File

@@ -40,6 +40,7 @@ import {
} from "@/utils/secureCredentials"; } from "@/utils/secureCredentials";
import { store } from "@/utils/store"; import { store } from "@/utils/store";
import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync"; import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync";
import { APP_VERSION } from "@/utils/version";
interface Server { interface Server {
address: string; address: string;
@@ -53,7 +54,7 @@ const initialApi = (() => {
const id = getOrSetDeviceId(); const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync(); const deviceName = getDeviceNameSync();
const jellyfinInstance = new Jellyfin({ const jellyfinInstance = new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.54.1" }, clientInfo: { name: "Streamyfin", version: APP_VERSION },
deviceInfo: { deviceInfo: {
name: deviceName, name: deviceName,
id, id,
@@ -135,7 +136,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const id = getOrSetDeviceId(); const id = getOrSetDeviceId();
const deviceName = getDeviceNameSync(); const deviceName = getDeviceNameSync();
return new Jellyfin({ return new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.54.1" }, clientInfo: { name: "Streamyfin", version: APP_VERSION },
deviceInfo: { deviceInfo: {
name: deviceName, name: deviceName,
id, id,
@@ -169,7 +170,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return { return {
authorization: `MediaBrowser Client="Streamyfin", Device=${ authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS" Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.54.1"`, }, DeviceId="${deviceId}", Version="${APP_VERSION}"`,
}; };
}, [deviceId]); }, [deviceId]);

View File

@@ -1,191 +0,0 @@
/**
* SyncPlay Controller — public playback API exposed to consumers.
*
* Methods are fire-and-forget by design: SyncPlay HTTP responses don't
* carry useful info (the real state arrives via WebSocket broadcast).
* Wrap calls in try/catch so transient network errors don't reach the UI.
*/
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import type { SyncPlayManager } from "./Manager";
import {
getItemsForPlayback,
type TranslateOptions,
translateItemsForPlayback,
} from "./transport/queueTranslation";
export interface PlayOptions extends TranslateOptions {
items?: BaseItemDto[];
ids?: string[];
startIndex?: number;
startPositionTicks?: number;
}
export class Controller {
private manager!: SyncPlayManager;
init(manager: SyncPlayManager): void {
this.manager = manager;
}
/** Toggle play/pause for the whole group. */
playPause(): void {
if (this.manager.isPlaying()) {
this.pause();
} else {
this.unpause();
}
}
/** Resume the group's playback. */
unpause(): void {
this.manager.markPendingPlaybackCommand("Unpause");
try {
getSyncPlayApi(this.manager.getApiClient()).syncPlayUnpause();
} catch (error) {
console.error("SyncPlay Controller.unpause failed", error);
}
}
/** Pause the group's playback. */
pause(): void {
this.manager.markPendingPlaybackCommand("Pause");
try {
getSyncPlayApi(this.manager.getApiClient()).syncPlayPause();
} catch (error) {
console.error("SyncPlay Controller.pause failed", error);
}
// Pause locally too so the user sees instant feedback.
this.manager.getPlayerWrapper().localPause();
}
/** Seek the group's playback. `positionTicks` is in ticks (1ms = 10000 ticks). */
seek(positionTicks: number): void {
try {
getSyncPlayApi(this.manager.getApiClient()).syncPlaySeek({
seekRequestDto: { PositionTicks: positionTicks },
});
} catch (error) {
console.error("SyncPlay Controller.seek failed", error);
}
}
/**
* Start playback in the group. Expands containers (Series, Season,
* BoxSet, Playlist, single Episode w/ autoplay) into the real
* playable queue before broadcasting.
*
* Resolves once the SetNewQueue request completes; the server then
* broadcasts a PlayQueue update and Play command to every member.
*/
async play(options: PlayOptions): Promise<void> {
const api = this.manager.getApiClient();
const sendPlayRequest = async (items: BaseItemDto[]) => {
const queue = items
.map((item) => item.Id)
.filter((id): id is string => typeof id === "string");
await getSyncPlayApi(api).syncPlaySetNewQueue({
playRequestDto: {
PlayingQueue: queue,
PlayingItemPosition: options.startIndex ?? 0,
StartPositionTicks: options.startPositionTicks ?? 0,
},
});
};
try {
const sourceItems = options.items
? options.items
: await getItemsForPlayback(api, options.ids ?? []);
const items = await translateItemsForPlayback(api, sourceItems, options);
await sendPlayRequest(items);
} catch (error) {
console.error("SyncPlay Controller.play failed", error);
throw error;
}
}
/** Stop the group's playback. */
stop(): void {
try {
getSyncPlayApi(this.manager.getApiClient()).syncPlayStop();
} catch (error) {
console.error("SyncPlay Controller.stop failed", error);
}
}
/** Jump to the next item in the group's queue. */
nextItem(): void {
try {
getSyncPlayApi(this.manager.getApiClient()).syncPlayNextItem({
nextItemRequestDto: {
PlaylistItemId: this.manager
.getQueueCore()
.getCurrentPlaylistItemId(),
} as unknown as Parameters<
ReturnType<typeof getSyncPlayApi>["syncPlayNextItem"]
>[0]["nextItemRequestDto"],
});
} catch (error) {
console.error("SyncPlay Controller.nextItem failed", error);
}
}
/** Jump to the previous item in the group's queue. */
previousItem(): void {
try {
getSyncPlayApi(this.manager.getApiClient()).syncPlayPreviousItem({
previousItemRequestDto: {
PlaylistItemId: this.manager
.getQueueCore()
.getCurrentPlaylistItemId(),
} as unknown as Parameters<
ReturnType<typeof getSyncPlayApi>["syncPlayPreviousItem"]
>[0]["previousItemRequestDto"],
});
} catch (error) {
console.error("SyncPlay Controller.previousItem failed", error);
}
}
/** Jump to a specific item in the queue by playlist item id. */
setCurrentPlaylistItem(playlistItemId: string): void {
try {
getSyncPlayApi(this.manager.getApiClient()).syncPlaySetPlaylistItem({
setPlaylistItemRequestDto: { PlaylistItemId: playlistItemId },
});
} catch (error) {
console.error("SyncPlay Controller.setCurrentPlaylistItem failed", error);
}
}
/**
* Jump the group to `item`. If the item is already in the current queue
* (by `Id`), dispatches a cheap `SetPlaylistItem` so the queue stays
* intact. Otherwise starts a new playback request, which replaces the
* group's queue (matches jellyfin-web's playbackManager.play behavior
* when picking an episode from a different series/season).
*/
goToItem(item: BaseItemDto): void {
if (!item.Id) {
console.warn("SyncPlay Controller.goToItem called without item.Id");
return;
}
const queueEntry = this.manager
.getQueueCore()
.getPlaylist()
.find((q) => q.Id === item.Id);
if (queueEntry?.PlaylistItemId) {
this.setCurrentPlaylistItem(queueEntry.PlaylistItemId);
return;
}
void this.play({
ids: [item.Id],
startPositionTicks: item.UserData?.PlaybackPositionTicks ?? 0,
});
}
}
export default Controller;

Some files were not shown because too many files have changed in this diff Show More