Compare commits

..

44 Commits

Author SHA1 Message Date
Alex Kim
1e69a3b6c5 Update to MPVKIT 0.41 2026-05-30 22:24:07 +10:00
Fredrik Burmester
0bf8fac079 chore: update pr template
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
🌐 Translation Sync / sync-translations (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 (lint) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Waiting to run
2026-05-30 10:41:24 +02:00
Fredrik Burmester
37b51abd34 chore: deps 2026-05-30 10:41:10 +02:00
Fredrik Burmester
6fe464088b fix(mpv): prevent UI freeze on player exit by tearing down mpv off main thread
mpv_terminate_destroy() blocks until mpv's threads (including the
vo_avfoundation output thread) are joined, and that teardown needs the
main run loop to complete. Calling it via queue.sync from MpvPlayerView
deinit (main thread) deadlocked/froze the UI on playback exit.

Remove the wakeup callback synchronously while self is still alive, then
run mpv_terminate_destroy on the serial queue via async so deinit returns
immediately and the main thread is never blocked. Also release the PiP
timebase/controller in deinit.
2026-05-30 10:40:10 +02:00
Fredrik Burmester
769c7a2432 fix: restore nested dropdown sections for expo 55 on iOS using nested ContextMenus 2026-05-30 10:13:40 +02:00
Fredrik Burmester
62c86533b1 fix(settings): preserve app defaults for unlocked plugin values 2026-05-30 10:03:56 +02:00
Fredrik Burmester
4fc78f006d fix: use correct back button 2026-05-30 10:03:56 +02:00
Fredrik Burmester
ab0957044f fix: use correct back button 2026-05-30 10:03:56 +02:00
Fredrik Burmester
407ef3f51e chore: add skills to gitignore 2026-05-30 10:03:55 +02:00
Fredrik Burmester
0e531da2e0 fix(watchlists): invalidate watchlists list query on add/remove 2026-05-30 10:03:55 +02:00
Gauvain
0f86c776ba feat(player): add chapter markers and chapter list (#1586)
Co-authored-by: retardgerman <78982850+retardgerman@users.noreply.github.com>
2026-05-30 09:22:23 +02:00
lostb1t
07b79de203 fix: Do not cache background request for mediasources (#1602) 2026-05-30 09:11:59 +02:00
renovate[bot]
cf91c4c682 chore(deps): Update dependency @react-native-community/netinfo to v12 (#1457)
Some checks failed
🛡️ 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
🏗️ 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
🚦 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
2026-05-27 22:44:18 +02:00
renovate[bot]
1545790528 chore(deps): Lock file maintenance (#1595)
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
2026-05-27 15:38:42 +02:00
renovate[bot]
11ec778bd8 chore(deps): Update github/codeql-action action to v4.36.0 (#1593)
Some checks failed
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (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
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (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 (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-26 15:23:11 +02:00
renovate[bot]
0c6ef5cbda chore(deps): Update maxim-lobanov/setup-xcode digest to ed7a3b1 (#1581) 2026-05-26 13:34:48 +02:00
renovate[bot]
1e14c7ec46 chore(deps): Update actions/cache action to v5.0.5 (#1582) 2026-05-26 11:42:23 +02:00
renovate[bot]
c8ddb9a892 chore(deps): Update actions/upload-artifact action to v7 (#1584) 2026-05-26 11:20:23 +02:00
renovate[bot]
9ee71a002d chore(deps): Update oven-sh/setup-bun action to v2.2.0 (#1583)
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
2026-05-26 09:40:57 +02:00
renovate[bot]
c950408bdb chore(deps): Update dependency expo-doctor to v1.19.7 (#1578)
Some checks failed
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (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
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (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 (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
2026-05-24 14:00:03 +02:00
renovate[bot]
1ac0644a57 chore(deps): Pin expo/expo-github-action action to b184ff8 (#1580)
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
🛡️ 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
2026-05-22 12:32:51 +02:00
Gauvain
0aa2dc5924 feat: Enables iOS TV builds (#1422) 2026-05-22 08:49:15 +02:00
renovate[bot]
e7f200a114 chore(deps): Update dependency i18next to v26 (#1573)
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
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (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
2026-05-22 01:32:27 +02:00
renovate[bot]
da9afacbf7 chore(deps): Update dependency react-i18next to v17 (#1574) 2026-05-22 01:07:52 +02:00
renovate[bot]
80fdd579f3 chore(deps): Update dependency @react-native-community/cli to v20.1.3 (#1571)
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
🔒 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 (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 / 🚑 Expo Doctor 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-05-21 02:28:38 +02:00
renovate[bot]
f79cf1925d chore(deps): Update dependency @react-native-tvos/config-tv to v0.1.6 (#1430) 2026-05-21 00:18:29 +02:00
renovate[bot]
8bb0d845a2 chore(deps): Update dependency lint-staged to v17 (#1570) 2026-05-20 23:29:39 +02:00
renovate[bot]
c7cd8217c9 chore(deps): Update dependency react-i18next to v16.5.8 (#1500)
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
🔒 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
2026-05-20 23:03:35 +02:00
renovate[bot]
235ba1473f chore(deps): Update dependency lint-staged to v16.4.0 (#1507) 2026-05-20 22:40:20 +02:00
renovate[bot]
284a4e3d41 chore(deps): Update dependency expo-doctor to v1.18.22 (#1506) 2026-05-20 19:10:36 +02:00
renovate[bot]
1fd3574520 chore(deps): Update actions/github-script action to v9 (#1568) 2026-05-20 18:36:38 +02:00
renovate[bot]
f1188c090a chore(deps): Update peter-evans/create-pull-request action to v8.1.1 (#1566) 2026-05-20 17:37:53 +02:00
renovate[bot]
1321a5c000 chore(deps): Update actions/dependency-review-action action to v5 (#1567) 2026-05-20 17:05:10 +02:00
renovate[bot]
52bc5e912d chore(deps): Update actions/dependency-review-action action to v4.9.0 (#1502) 2026-05-20 15:28:34 +02:00
renovate[bot]
7bccafc476 chore(deps): Update actions/dependency-review-action action to v4.8.3 (#1499)
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
🔒 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 (lint) (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 (typecheck) (push) Has been cancelled
2026-05-20 14:49:24 +02:00
renovate[bot]
8df61838d4 chore(deps): Update actions/cache action to v5.0.5 (#1429) 2026-05-20 13:43:17 +02:00
renovate[bot]
55776d887f chore(deps): Update maxim-lobanov/setup-xcode digest to ed7a3b1 (#1498) 2026-05-20 13:17:20 +02:00
renovate[bot]
2e7079cb5a chore(deps): Update github/codeql-action action to v4.35.5 (#1508) 2026-05-20 12:54:50 +02:00
renovate[bot]
428455f6a6 chore(deps): Update actions/setup-node action to v6.4.0 (#1503)
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
🔒 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 (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2026-05-20 01:08:17 +02:00
renovate[bot]
8c749cdc4d chore(deps): Update oven-sh/setup-bun action to v2.2.0 (#1509) 2026-05-20 00:44:45 +02:00
renovate[bot]
7ed0c00ce7 chore(deps): Update actions/upload-artifact action to v7 (#1510) 2026-05-20 00:24:58 +02:00
renovate[bot]
222ae69644 chore(deps): Update marocchino/sticky-pull-request-comment action to v3 (#1512) 2026-05-20 00:03:36 +02:00
renovate[bot]
fec8df37f7 chore(deps): Pin dependencies (#1537) 2026-05-19 23:40:40 +02:00
renovate[bot]
0e0e722e1c chore(deps): Update crowdin/github-action action to v2.16.2 (#1504)
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
🔒 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
2026-05-19 19:58:56 +02:00
33 changed files with 1570 additions and 1462 deletions

View File

@@ -1,91 +1,54 @@
<!-- <!--
Pull Request Template for Streamyfin Use a conventional commit title for the PR title,
==================================== for example `feat(auth): add MFA`
Use this template to help reviewers understand the purpose of your PR All sections below are required. Write N/A if a section is not applicable.
and to ensure all necessary checks are completed before merging. If you use AI to help implement this PR, you must declare it below. It's very important that the feature or fix implemented has been tested thoroughly by you personally on all target platforms. Only adding AI generated code without proper testing is not allowed and this PR will be closed immediately.
--> -->
# 📦 Pull Request # 📦 Pull Request
## 🔖 Summary
<!-- <!--
A concise description of the changes introduced by this PR. 🤖 AI ASSISTED?
Example: Uncomment the line below if AI was used to assist with this PR:
“Add real-time currency conversion widget to dashboard.” -->
<!--
[![AI Assisted](https://img.shields.io/badge/AI_Assisted-18181b?style=for-the-badge&logo=openai&logoColor=white)](#) -->
## 📝 Description
<!--
A short description of the changes and why you're making them.
Example: “Add option to clean image cache, to mitigate stuck/blank movie poster issues.”
--> -->
## 🏷️ Ticket / Issue ## 🏷️ Ticket / Issue
<!-- <!--
Link to the related ticket, issue or user story. Link to the related ticket, issue or user story.
You can also indicate if this PR supersedes a previous one. Example: Fixes #123
Example:
- Closes #123
- Fixes STREAMYFIN-456
- Resolves #789
- Supersedes #120
- Related: #130
--> -->
## 🛠️ Whats Changed
<!-- Use a Conventional Commit in the PR title, e.g., `feat(auth): add MFA`.
If this PR introduces a breaking change, include a `BREAKING CHANGE:` block in the description.
Spec: https://www.conventionalcommits.org/ -->
- Type: feat | fix | docs | style | refactor | perf | test | chore | build | ci | revert
- Scope (optional): e.g., auth, billing, mobile
- Short summary: what changed and why (12 lines)
-->
## 📋 Details
<!--
Provide more context or background. Explain any non-obvious decisions.
Include screenshots or GIFs for UI changes if applicable.
-->
### ⚠️ Breaking Changes
<!-- List any breaking API/contract changes and migration guidance. If none, write “None”. -->
### 🔐 Security & Privacy Impact
<!-- Data touched, new permissions/scopes, PII, secrets, threat considerations. If none, write “None”. -->
### ⚡ Performance Impact
<!-- Hot paths, memory/CPU/latency implications, benchmarks if available. -->
### 🖼️ Screenshots / GIFs (if UI) ### 🖼️ Screenshots / GIFs (if UI)
<!-- Before/After, dark mode, responsive states. --> <!--
Include screenshots of relevant UI changes for both Android and iOS.
Before/After, responsive states (if relevant).
-->
## ✅ Checklist ## ✅ Checklist
<!-- <!--
Review and check off items as you complete them. Review and check off items as you complete them.
--> -->
- [ ] Ive read the [contribution guidelines](CONTRIBUTING.md) - [ ] Ive read the [contribution guidelines](CONTRIBUTING.md)
- [ ] Code follows project style and passes lint/format (`npm|pnpm|yarn|bun` scripts) - [ ] Verified that changes behave as expected for all platforms
- [ ] Type checks pass (tsc/biome/etc.) - [ ] Code passes lint/formatting and type checks (`tsc`/`biome`)
- [ ] Docs updated (README/ADR/usage/API) - [ ] No secrets, hardcoded credentials, or private config files are included
- [ ] No secrets/credentials included; env vars documented - [ ] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
- [ ] Release notes/CHANGELOG entry added (if applicable)
- [ ] Verified locally that changes behave as expected
## 🔍 Testing Instructions ## 🔍 Testing Instructions
<!-- <!--
Describe how reviewers can test your changes. Describe how reviewers can test your changes. This will help the PR get merged faster.
Example: Example:
1. `git fetch origin pull/<PR_ID>/head:branchname && git checkout branchname` 1. Open the settings page and scroll to the bottom
2. Install deps: `npm|pnpm|yarn|bun install` 2. Verify that the clear data button is visible and pressable
3. Start service/app: `npm|pnpm|yarn|bun run [target]` (e.g., `npm run ios` or `bun run android:tv`) 3. Verify that when you click the clear data button, a dialog appears prompting you to confirm
4. Run tests: `npm|pnpm|yarn|bun test` 4. Verify that when you click the confirm button, the data is cleared and a toast message is displayed
5. Verification steps:
- [ ] Expected UI/endpoint behavior
- [ ] Logs show no errors
- [ ] Edge cases covered (list)
--> -->
## ⚙️ Deployment Notes
<!--
Describe any deployment considerations such as config, environment vars, or native builds.
-->
## 📝 Additional Notes
<!--
Any other information or references related to this PR.
-->

View File

@@ -26,7 +26,7 @@ jobs:
steps: steps:
- name: 🔍 Get PR and Artifacts - name: 🔍 Get PR and Artifacts
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with: with:
script: | script: |
// Check if we're running from a fork (more precise detection) // Check if we're running from a fork (more precise detection)
@@ -188,6 +188,17 @@ jobs:
if (latestAppsRun) { if (latestAppsRun) {
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`); console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
// Map job names to our build targets. Declared outside the try so
// the catch fallback can reuse the same keys.
const jobMappings = {
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
'iOS': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone'],
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)', 'build-ios-phone-unsigned'],
'tvOS': ['🍎 Build tvOS IPA', 'build-ios-tv'],
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)', 'build-ios-tv-unsigned']
};
try { try {
// Get all jobs for this workflow run // Get all jobs for this workflow run
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({ const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
@@ -216,13 +227,6 @@ jobs:
return; // Exit early return; // Exit early
} }
// Map job names to our build targets
const jobMappings = {
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
'iOS Phone': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone']
};
// Create individual status for each job // Create individual status for each job
for (const [platform, jobNames] of Object.entries(jobMappings)) { for (const [platform, jobNames] of Object.entries(jobMappings)) {
const job = jobs.jobs.find(j => const job = jobs.jobs.find(j =>
@@ -236,7 +240,9 @@ jobs:
conclusion: job.conclusion, conclusion: job.conclusion,
url: job.html_url, url: job.html_url,
runId: latestAppsRun.id, runId: latestAppsRun.id,
created_at: job.started_at || latestAppsRun.created_at created_at: job.started_at || latestAppsRun.created_at,
started_at: job.started_at,
completed_at: job.completed_at
}; };
console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`); console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`);
} else { } else {
@@ -247,22 +253,30 @@ jobs:
conclusion: latestAppsRun.conclusion, conclusion: latestAppsRun.conclusion,
url: latestAppsRun.html_url, url: latestAppsRun.html_url,
runId: latestAppsRun.id, runId: latestAppsRun.id,
created_at: latestAppsRun.created_at created_at: latestAppsRun.created_at,
started_at: latestAppsRun.run_started_at,
completed_at: latestAppsRun.updated_at
}; };
} }
} }
} catch (error) { } catch (error) {
console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message); console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message);
// Fallback to workflow-level status // Fallback to workflow-level status for every build target.
buildStatuses['Android Phone'] = buildStatuses['Android TV'] = buildStatuses['iOS Phone'] = { // Keys must match jobMappings / buildTargets statusKey values.
const fallbackStatus = {
name: latestAppsRun.name, name: latestAppsRun.name,
status: latestAppsRun.status, status: latestAppsRun.status,
conclusion: latestAppsRun.conclusion, conclusion: latestAppsRun.conclusion,
url: latestAppsRun.html_url, url: latestAppsRun.html_url,
runId: latestAppsRun.id, runId: latestAppsRun.id,
created_at: latestAppsRun.created_at created_at: latestAppsRun.created_at,
started_at: latestAppsRun.run_started_at,
completed_at: latestAppsRun.updated_at
}; };
for (const platform of Object.keys(jobMappings)) {
buildStatuses[platform] = fallbackStatus;
}
} }
// Collect artifacts if any job has completed successfully // Collect artifacts if any job has completed successfully
@@ -353,10 +367,12 @@ jobs:
// Process each expected build target individually // Process each expected build target individually
const buildTargets = [ const buildTargets = [
{ name: 'Android Phone', platform: '🤖', device: '📱', statusKey: 'Android Phone', artifactPattern: /android.*phone/i }, { name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
{ name: 'Android TV', platform: '🤖', device: '📺', statusKey: 'Android TV', artifactPattern: /android.*tv/i }, { name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
{ name: 'iOS Phone', platform: '🍎', device: '📱', statusKey: 'iOS Phone', artifactPattern: /ios.*phone/i }, { name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i },
{ name: 'iOS TV', platform: '🍎', device: '📺', statusKey: 'iOS TV', artifactPattern: /ios.*tv/i } { name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i },
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /ios.*tv.*ipa(?!.*unsigned)/i },
{ name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
]; ];
for (const target of buildTargets) { for (const target of buildTargets) {
@@ -371,16 +387,31 @@ jobs:
let status = '⏳ Pending'; let status = '⏳ Pending';
let downloadLink = '*Waiting for build...*'; let downloadLink = '*Waiting for build...*';
// Special case for iOS TV - show as disabled // tvOS builds are temporarily disabled until feat/tv-interface
if (target.name === 'iOS TV') { // is merged - show them as disabled instead of stuck pending.
if (target.name === 'tvOS' || target.name === 'tvOS Unsigned') {
status = '💤 Disabled'; status = '💤 Disabled';
downloadLink = '*Disabled for now*'; downloadLink = '*Disabled until feat/tv-interface is merged*';
} else if (matchingStatus) { } else if (matchingStatus) {
if (matchingStatus.conclusion === 'success' && matchingArtifact) { if (matchingStatus.conclusion === 'success' && matchingArtifact) {
status = '✅ Complete'; status = '✅ Complete';
const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`; const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`;
const fileType = target.name.includes('Android') ? 'APK' : 'IPA'; const fileType = target.name.includes('Android') ? 'APK' : 'IPA';
downloadLink = `[📥 Download ${fileType}](${directLink})`;
// Format file size
const sizeInMB = (matchingArtifact.size_in_bytes / (1024 * 1024)).toFixed(1);
const sizeInfo = `(${sizeInMB} MB)`;
// Calculate build duration
let durationInfo = '';
if (matchingStatus.started_at && matchingStatus.completed_at) {
const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
const durationMin = Math.floor(durationMs / 60000);
const durationSec = Math.floor((durationMs % 60000) / 1000);
durationInfo = ` - ${durationMin}m ${durationSec}s`;
}
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
} else if (matchingStatus.conclusion === 'failure') { } else if (matchingStatus.conclusion === 'failure') {
status = `❌ [Failed](${matchingStatus.url})`; status = `❌ [Failed](${matchingStatus.url})`;
downloadLink = '*Build failed*'; downloadLink = '*Build failed*';
@@ -408,7 +439,7 @@ jobs:
} }
} }
commentBody += `| ${target.platform} ${target.name.split(' ')[0]} | ${target.device} ${target.name.split(' ')[1]} | ${status} | ${downloadLink} |\n`; commentBody += `| ${target.platform} ${target.name} | ${target.device} | ${status} | ${downloadLink} |\n`;
} }
commentBody += `\n`; commentBody += `\n`;

View File

@@ -41,12 +41,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -60,7 +60,7 @@ jobs:
bun run submodule-reload bun run submodule-reload
- name: 💾 Cache Gradle global - name: 💾 Cache Gradle global
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -73,7 +73,7 @@ jobs:
run: bun run prebuild run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle) - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: android/.gradle path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -88,7 +88,7 @@ jobs:
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
- name: 📤 Upload APK artifact - name: 📤 Upload APK artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }} name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
path: | path: |
@@ -124,12 +124,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -143,7 +143,7 @@ jobs:
bun run submodule-reload bun run submodule-reload
- name: 💾 Cache Gradle global - name: 💾 Cache Gradle global
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -156,7 +156,7 @@ jobs:
run: bun run prebuild:tv run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle) - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: android/.gradle path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -171,7 +171,7 @@ jobs:
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
- name: 📤 Upload APK artifact - name: 📤 Upload APK artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }} name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
path: | path: |
@@ -195,12 +195,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
@@ -216,12 +216,12 @@ jobs:
run: bun run prebuild run: bun run prebuild
- name: 🔧 Setup Xcode - name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1 uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with: with:
xcode-version: "26.2" xcode-version: "26.2"
- name: 🏗️ Setup EAS - name: 🏗️ Setup EAS
uses: expo/expo-github-action@main uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with: with:
eas-version: latest eas-version: latest
token: ${{ secrets.EXPO_TOKEN }} token: ${{ secrets.EXPO_TOKEN }}
@@ -236,7 +236,7 @@ jobs:
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
- name: 📤 Upload IPA artifact - name: 📤 Upload IPA artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }} name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
path: build-*.ipa path: build-*.ipa
@@ -259,12 +259,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
@@ -280,7 +280,7 @@ jobs:
run: bun run prebuild run: bun run prebuild
- name: 🔧 Setup Xcode - name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1 uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with: with:
xcode-version: "26.2" xcode-version: "26.2"
@@ -293,73 +293,133 @@ jobs:
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
- name: 📤 Upload IPA artifact - name: 📤 Upload IPA artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }} name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }}
path: build/*.ipa path: build/*.ipa
retention-days: 7 retention-days: 7
# Disabled for now - uncomment when ready to build iOS TV build-ios-tv:
# build-ios-tv: # Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin')) # Re-enable by removing the `false &&` prefix below.
# runs-on: macos-26 if: false && (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
# name: 🍎 Build iOS IPA (TV) runs-on: macos-26
# permissions: name: 🍎 Build tvOS IPA
# contents: read permissions:
# contents: read
# steps:
# - name: 📥 Checkout code steps:
# uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: 📥 Checkout code
# with: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# ref: ${{ github.event.pull_request.head.sha || github.sha }} with:
# fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha || github.sha }}
# submodules: recursive fetch-depth: 0
# show-progress: false submodules: recursive
# show-progress: false
# - name: 🍞 Setup Bun
# uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 - name: 🍞 Setup Bun
# with: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
# bun-version: latest with:
# bun-version: latest
# - name: 💾 Cache Bun dependencies
# uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - name: 💾 Cache Bun dependencies
# with: uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
# path: ~/.bun/install/cache with:
# key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} path: ~/.bun/install/cache
# restore-keys: | key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
# ${{ runner.os }}-bun-cache restore-keys: |
# ${{ runner.os }}-bun-cache
# - name: 📦 Install dependencies and reload submodules
# run: | - name: 📦 Install dependencies and reload submodules
# bun install --frozen-lockfile run: |
# bun run submodule-reload bun install --frozen-lockfile
# bun run submodule-reload
# - name: 🛠️ Generate project files
# run: bun run prebuild:tv - name: 🛠️ Generate project files
# run: bun run prebuild:tv
# - name: 🔧 Setup Xcode
# uses: maxim-lobanov/setup-xcode@v1 - name: 🔧 Setup Xcode
# with: uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
# xcode-version: '26.0.1' with:
# xcode-version: "26.2"
# - name: 🏗️ Setup EAS
# uses: expo/expo-github-action@main - name: 🏗️ Setup EAS
# with: uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
# eas-version: latest with:
# token: ${{ secrets.EXPO_TOKEN }} eas-version: latest
# eas-cache: true token: ${{ secrets.EXPO_TOKEN }}
# eas-cache: true
# - name: 🚀 Build iOS app
# env: - name: 🚀 Build iOS app
# EXPO_TV: 1 env:
# run: eas build -p ios --local --non-interactive EXPO_TV: 1
# run: eas build -p ios --local --non-interactive
# - name: 📅 Set date tag
# run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV - name: 📅 Set date tag
# run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
# - name: 📤 Upload IPA artifact
# uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - name: 📤 Upload IPA artifact
# with: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }} with:
# path: build-*.ipa name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
# retention-days: 7 path: build-*.ipa
retention-days: 7
build-ios-tv-unsigned:
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
# Re-enable by removing the `false &&` prefix below.
if: false && (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: macos-26
name: 🍎 Build tvOS IPA (Unsigned)
permissions:
contents: read
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 🛠️ Generate project files
run: bun run prebuild:tv
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
xcode-version: "26.2"
- name: 🚀 Build iOS app
env:
EXPO_TV: 1
run: bun run ios:unsigned-build:tv ${{ github.event_name == 'pull_request' && '-- --verbose' || '' }}
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
path: build/*.ipa
retention-days: 7

View File

@@ -27,12 +27,12 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: | path: |
~/.bun/install/cache ~/.bun/install/cache

View File

@@ -27,13 +27,13 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🏁 Initialize CodeQL - name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
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@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
- name: 🧪 Perform CodeQL Analysis - name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0

View File

@@ -28,7 +28,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin - name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@5587c43063e52090026857d386174d2599ad323b # v2.14.1 uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
with: with:
upload_sources: true upload_sources: true
upload_translations: true upload_translations: true

View File

@@ -25,7 +25,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 - uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
if: always() && (steps.lint_pr_title.outputs.error_message != null) if: always() && (steps.lint_pr_title.outputs.error_message != null)
with: with:
header: pr-title-lint-error header: pr-title-lint-error
@@ -39,7 +39,7 @@ jobs:
``` ```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }} - if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with: with:
header: pr-title-lint-error header: pr-title-lint-error
delete: true delete: true
@@ -57,7 +57,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Dependency Review - name: Dependency Review
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with: with:
fail-on-severity: high fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }} base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
@@ -76,7 +76,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version: latest bun-version: latest
@@ -107,12 +107,12 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: '24.x' node-version: '24.x'
- name: "🍞 Setup Bun" - name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version: latest bun-version: latest

View File

@@ -21,14 +21,14 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: '24.x' node-version: '24.x'
cache: 'npm' cache: 'npm'
- name: 🔍 Extract minor version from app.json - name: 🔍 Extract minor version from app.json
id: minor id: minor
uses: actions/github-script@main uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
with: with:
result-encoding: string result-encoding: string
script: | script: |
@@ -54,7 +54,7 @@ jobs:
dry_run: no-push dry_run: no-push
- name: 📬 Commit and create pull request - name: 📬 Commit and create pull request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 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/bug_report.yml
branch: ci-update-bug-report branch: ci-update-bug-report

2
.gitignore vendored
View File

@@ -73,3 +73,5 @@ modules/background-downloader/android/build/*
# ios:unsigned-build Artifacts # ios:unsigned-build Artifacts
build/ build/
.agents/skills/**
skills-lock.json

View File

@@ -124,8 +124,8 @@
[ [
"./plugins/withGitPod.js", "./plugins/withGitPod.js",
{ {
"podName": "MPVKit-GPL", "podName": "MPVKit",
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec" "podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
} }
] ]
], ],

View File

@@ -9,6 +9,7 @@ import useRouter from "@/hooks/useAppRouter";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast"); const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions"; import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { userAtom } from "@/providers/JellyfinProvider"; import { userAtom } from "@/providers/JellyfinProvider";
@@ -47,15 +48,7 @@ export default function IndexLayout() {
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
title: t("home.downloads.downloads_title"), title: t("home.downloads.downloads_title"),
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
@@ -66,15 +59,7 @@ export default function IndexLayout() {
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
@@ -84,15 +69,7 @@ export default function IndexLayout() {
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
@@ -102,15 +79,7 @@ export default function IndexLayout() {
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
@@ -120,15 +89,7 @@ export default function IndexLayout() {
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
@@ -138,15 +99,7 @@ export default function IndexLayout() {
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
@@ -156,15 +109,7 @@ export default function IndexLayout() {
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
@@ -174,15 +119,7 @@ export default function IndexLayout() {
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
@@ -210,15 +147,7 @@ export default function IndexLayout() {
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
@@ -228,15 +157,7 @@ export default function IndexLayout() {
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
@@ -246,15 +167,7 @@ export default function IndexLayout() {
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
@@ -264,15 +177,7 @@ export default function IndexLayout() {
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
@@ -282,15 +187,7 @@ export default function IndexLayout() {
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
@@ -300,15 +197,7 @@ export default function IndexLayout() {
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
@@ -318,15 +207,7 @@ export default function IndexLayout() {
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}} }}
/> />
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
@@ -336,11 +217,7 @@ export default function IndexLayout() {
name='collections/[collectionId]' name='collections/[collectionId]'
options={{ options={{
title: "", title: "",
headerLeft: () => ( headerLeft: () => <HeaderBackButton />,
<Pressable onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerShown: true, headerShown: true,
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",

View File

@@ -30,8 +30,10 @@ const Page: React.FC = () => {
ItemFields.MediaStreams, ItemFields.MediaStreams,
]); ]);
// Lazily preload item with full media sources in background // Lazily preload item with full media sources in background — never cache
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []); const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, [], {
gcTime: 0,
});
const opacity = useSharedValue(1); const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => { const animatedStyle = useAnimatedStyle(() => {

View File

@@ -375,8 +375,9 @@ function Layout() {
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
dehydrateOptions: { dehydrateOptions: {
shouldDehydrateQuery: (query) => { shouldDehydrateQuery: (query) => {
// Only persist successful queries return (
return query.state.status === "success"; query.state.status === "success" && query.options.gcTime !== 0
);
}, },
}, },
}} }}

1430
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { type Href } from "expo-router"; import { type Href } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@@ -195,9 +196,30 @@ export const DownloadItems: React.FC<DownloadProps> = ({
); );
} }
const downloadDetailsPromises = items.map(async (item) => { const downloadDetailsPromises = items.map(async (item) => {
// Ensure the snapshot we store offline carries the Chapters array.
// Page-level fetches sometimes use a fields filter that omits it; the
// offline player would then render no chapter ticks / list.
let itemForDownload = item;
if (!itemForDownload.Chapters && itemForDownload.Id) {
try {
const enriched = await getUserLibraryApi(api).getItem({
itemId: itemForDownload.Id,
userId: user.Id!,
});
if (enriched.data) {
itemForDownload = enriched.data;
}
} catch (e) {
console.warn(
"[DownloadItem] failed to refresh item for Chapters, falling back to original",
e,
);
}
}
const { mediaSource, audioIndex, subtitleIndex } = const { mediaSource, audioIndex, subtitleIndex } =
itemsNotDownloaded.length > 1 itemsNotDownloaded.length > 1
? getDefaultPlaySettings(item, settings!) ? getDefaultPlaySettings(itemForDownload, settings!)
: { : {
mediaSource: selectedOptions?.mediaSource, mediaSource: selectedOptions?.mediaSource,
audioIndex: selectedOptions?.audioIndex, audioIndex: selectedOptions?.audioIndex,
@@ -206,7 +228,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const downloadDetails = await getDownloadUrl({ const downloadDetails = await getDownloadUrl({
api, api,
item, item: itemForDownload,
userId: user.Id!, userId: user.Id!,
mediaSource: mediaSource!, mediaSource: mediaSource!,
audioStreamIndex: audioIndex ?? -1, audioStreamIndex: audioIndex ?? -1,
@@ -218,7 +240,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
return { return {
url: downloadDetails?.url, url: downloadDetails?.url,
item, item: itemForDownload,
mediaSource: downloadDetails?.mediaSource, mediaSource: downloadDetails?.mediaSource,
}; };
}); });

View File

@@ -1,4 +1,4 @@
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui"; import { Button, ContextMenu, Host } from "@expo/ui/swift-ui";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
@@ -254,23 +254,39 @@ const PlatformDropdownComponent = ({
// Otherwise render as individual buttons // Otherwise render as individual buttons
if (radioOptions.length > 0) { if (radioOptions.length > 0) {
if (group.title) { if (group.title) {
// Use Picker for grouped options // Use a nested ContextMenu as a submenu for grouped options
const selectedOption = radioOptions.find(
(opt) => opt.selected,
);
const displayTitle = selectedOption
? `${group.title}: ${selectedOption.label}`
: group.title;
items.push( items.push(
<Picker <ContextMenu key={`submenu-${groupIndex}`}>
key={`picker-${groupIndex}`} <ContextMenu.Trigger>
label={group.title} <Button>{displayTitle}</Button>
options={radioOptions.map((opt) => opt.label)} </ContextMenu.Trigger>
variant='menu' <ContextMenu.Items>
selectedIndex={radioOptions.findIndex( {radioOptions.map((option, optionIndex) => (
(opt) => opt.selected, <Button
)} key={`radio-${groupIndex}-${optionIndex}`}
onOptionSelected={(event: any) => { systemImage={
const index = event.nativeEvent.index; option.selected
const selectedOption = radioOptions[index]; ? "checkmark.circle.fill"
selectedOption?.onPress(); : "circle"
onOptionSelect?.(selectedOption?.value); }
}} onPress={() => {
/>, option.onPress();
onOptionSelect?.(option.value);
}}
disabled={option.disabled}
>
{option.label}
</Button>
))}
</ContextMenu.Items>
</ContextMenu>,
); );
} else { } else {
// Render radio options as direct buttons // Render radio options as direct buttons

View File

@@ -0,0 +1,196 @@
/**
* A modal listing an item's chapters. Each row shows the chapter name and its
* timestamp; the current chapter is highlighted. Tapping a row seeks to that
* chapter and closes the modal. Player-agnostic — the seek is injected.
*/
import { Ionicons } from "@expo/vector-icons";
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { memo, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
import {
type ChapterEntry,
chapterStartsMs,
formatChapterTime,
sortedChapters,
} from "@/utils/chapters";
interface ChapterListProps {
visible: boolean;
chapters: ChapterInfo[] | null | undefined;
/** Current playback position in milliseconds (to highlight the row). */
currentPositionMs: number;
/** Seek the player to this millisecond position. */
onSeek: (positionMs: number) => void;
onClose: () => void;
}
const ROW_HEIGHT = 48;
function ChapterListComponent({
visible,
chapters,
currentPositionMs,
onSeek,
onClose,
}: ChapterListProps) {
const { t } = useTranslation();
const listRef = useRef<FlatList<ChapterEntry>>(null);
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
// Memoize starts so currentChapterIndex computation doesn't re-sort/filter
// every tick — chapters is the only input that drives the underlying array.
const starts = useMemo(() => chapterStartsMs(chapters), [chapters]);
const activeIndex = useMemo(() => {
let idx = -1;
for (let i = 0; i < starts.length; i++) {
if (currentPositionMs >= starts[i]) idx = i;
else break;
}
return idx;
}, [currentPositionMs, starts]);
// FlatList.initialScrollIndex only fires at first mount; <Modal> keeps its
// children mounted across visible toggles, so subsequent opens never scroll.
// Trigger an imperative scroll each time the sheet becomes visible.
useEffect(() => {
if (!visible || activeIndex < 0 || entries.length === 0) return;
const raf = requestAnimationFrame(() => {
listRef.current?.scrollToIndex({
index: activeIndex,
animated: false,
viewPosition: 0.5,
});
});
return () => cancelAnimationFrame(raf);
}, [visible, activeIndex, entries.length]);
return (
<Modal
visible={visible}
transparent
animationType='slide'
onRequestClose={onClose}
>
<Pressable onPress={onClose} style={styles.backdrop}>
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
<View style={styles.header}>
<Text style={styles.title}>{t("chapters.title")}</Text>
<Pressable
onPress={onClose}
hitSlop={10}
accessibilityRole='button'
accessibilityLabel={t("chapters.close")}
>
<Ionicons name='close' size={24} color={Colors.text} />
</Pressable>
</View>
<FlatList
ref={listRef}
data={entries}
keyExtractor={(item, index) => `${item.positionMs}-${index}`}
getItemLayout={(_, index) => ({
length: ROW_HEIGHT,
offset: ROW_HEIGHT * index,
index,
})}
onScrollToIndexFailed={(info) => {
// Required when getItemLayout is provided and the target index
// is outside the currently rendered window. Fallback to an
// offset-based scroll, then retry the precise scroll once a
// frame has elapsed.
listRef.current?.scrollToOffset({
offset: info.averageItemLength * info.index,
animated: false,
});
setTimeout(() => {
listRef.current?.scrollToIndex({
index: info.index,
animated: false,
viewPosition: 0.5,
});
}, 50);
}}
renderItem={({ item, index }) => {
const positionMs = item.positionMs;
const isActive = index === activeIndex;
return (
<Pressable
onPress={() => {
onSeek(positionMs);
onClose();
}}
style={[
styles.row,
isActive && { backgroundColor: `${Colors.primary}33` },
]}
>
<Text
style={[
styles.rowText,
{ color: isActive ? Colors.primary : Colors.text },
]}
numberOfLines={1}
>
{item.chapter.Name ||
t("chapters.chapter_number", { number: index + 1 })}
</Text>
<Text style={styles.rowTime}>
{formatChapterTime(positionMs)}
</Text>
</Pressable>
);
}}
/>
</Pressable>
</Pressable>
</Modal>
);
}
export const ChapterList = memo(ChapterListComponent);
const styles = StyleSheet.create({
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.6)",
},
sheet: {
backgroundColor: Colors.background,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: "70%",
paddingBottom: 24,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
},
title: {
color: Colors.text,
fontSize: 17,
fontWeight: "700",
},
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
height: ROW_HEIGHT,
},
rowText: {
fontSize: 15,
flex: 1,
},
rowTime: {
color: Colors.icon,
fontSize: 13,
marginLeft: 12,
},
});

View File

@@ -0,0 +1,87 @@
/**
* Chapter tick marks drawn as an absolute overlay over a progress slider.
* Renders nothing for media with one or zero chapters. `pointerEvents: "none"`
* so the slider underneath still receives touches.
*/
import { memo, useState } from "react";
import { type LayoutChangeEvent, PixelRatio, View } from "react-native";
import type { ChapterMarker } from "@/utils/chapters";
interface ChapterTicksProps {
/** Pre-computed markers (caller memoizes — avoids double-computing here). */
markers: ChapterMarker[];
/** Tick colour. */
color?: string;
/** Tick height in px — slightly less than the slider track thickness. */
height?: number;
/** Tick width in px — integer to avoid sub-pixel anti-aliasing. */
width?: number;
}
function ChapterTicksComponent({
markers,
// Semi-transparent black contrasts against both the filled progress
// (#fff) and the unfilled track (rgba(255,255,255,0.2)) so the ticks
// stay visible across the whole bar as playback advances.
color = "rgba(0,0,0,0.55)",
height = 14,
width = 2,
}: ChapterTicksProps) {
// Hooks must run unconditionally — keep them before any early return.
const [sliderWidth, setSliderWidth] = useState(0);
const handleLayout = (e: LayoutChangeEvent) => {
setSliderWidth(e.nativeEvent.layout.width);
};
// One chapter (typically a single marker at 0) is not worth marking.
if (markers.length <= 1) return null;
return (
<View
pointerEvents='none'
onLayout={handleLayout}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
// Let ticks taller than this container bleed beyond its bounds.
overflow: "visible",
}}
>
{sliderWidth > 0 &&
markers
// Skip the leading 0ms marker — it overlaps the slider start and
// adds visual noise at an already-rendered boundary.
.filter((marker) => marker.positionMs > 0)
.map((marker, index) => {
// Align both the position AND the width onto the device's
// physical pixel grid. Without this, fractional dp values land
// at different sub-pixel fractions per tick — Android samples
// each one differently and some ticks render visibly thicker.
const centerDp = (marker.percent / 100) * sliderWidth;
const left = PixelRatio.roundToNearestPixel(centerDp - width / 2);
const snappedWidth = PixelRatio.roundToNearestPixel(width);
return (
<View
key={`${marker.positionMs}-${index}`}
style={{
position: "absolute",
left,
top: "50%",
marginTop: -height / 2,
height,
width: snappedWidth,
backgroundColor: color,
}}
/>
);
})}
</View>
);
}
export const ChapterTicks = memo(ChapterTicksComponent);

View File

@@ -1,4 +1,4 @@
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui"; import { Button, ContextMenu, Host } from "@expo/ui/swift-ui";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage"; import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
@@ -49,32 +49,66 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
></Button> ></Button>
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Items> <ContextMenu.Items>
<Picker <ContextMenu>
label={t("library.filters.sort_by")} <ContextMenu.Trigger>
options={sortOptions.map((item) => <Button>
t(`home.settings.plugins.jellyseerr.order_by.${item}`), {`${t("library.filters.sort_by")}: ${t(
)} `home.settings.plugins.jellyseerr.order_by.${jellyseerrOrderBy}`,
variant='menu' )}`}
selectedIndex={sortOptions.indexOf( </Button>
jellyseerrOrderBy as unknown as string, </ContextMenu.Trigger>
)} <ContextMenu.Items>
onOptionSelected={(event: any) => { {sortOptions.map((item) => {
const index = event.nativeEvent.index; const label = t(
setJellyseerrOrderBy( `home.settings.plugins.jellyseerr.order_by.${item}`,
sortOptions[index] as unknown as JellyseerrSearchSort, );
); const isSelected =
}} jellyseerrOrderBy ===
/> (item as unknown as JellyseerrSearchSort);
<Picker return (
label={t("library.filters.sort_order")} <Button
options={orderOptions.map((item) => t(`library.filters.${item}`))} key={item}
variant='menu' systemImage={
selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)} isSelected ? "checkmark.circle.fill" : "circle"
onOptionSelected={(event: any) => { }
const index = event.nativeEvent.index; onPress={() =>
setJellyseerrSortOrder(orderOptions[index]); setJellyseerrOrderBy(
}} item as unknown as JellyseerrSearchSort,
/> )
}
>
{label}
</Button>
);
})}
</ContextMenu.Items>
</ContextMenu>
<ContextMenu>
<ContextMenu.Trigger>
<Button>
{`${t("library.filters.sort_order")}: ${t(
`library.filters.${jellyseerrSortOrder}`,
)}`}
</Button>
</ContextMenu.Trigger>
<ContextMenu.Items>
{orderOptions.map((item) => {
const label = t(`library.filters.${item}`);
const isSelected = jellyseerrSortOrder === item;
return (
<Button
key={item}
systemImage={
isSelected ? "checkmark.circle.fill" : "circle"
}
onPress={() => setJellyseerrSortOrder(item)}
>
{label}
</Button>
);
})}
</ContextMenu.Items>
</ContextMenu>
</ContextMenu.Items> </ContextMenu.Items>
</ContextMenu> </ContextMenu>
</Host> </Host>

View File

@@ -1,18 +1,34 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { Ionicons } from "@expo/vector-icons";
import type { FC } from "react"; import type {
import { View } from "react-native"; BaseItemDto,
ChapterInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { type FC, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider"; import { Slider } from "react-native-awesome-slider";
import { type SharedValue } from "react-native-reanimated"; import { type SharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChapterList } from "@/components/chapters/ChapterList";
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton"; import SkipButton from "./SkipButton";
import { TimeDisplay } from "./TimeDisplay"; import { TimeDisplay } from "./TimeDisplay";
import { TrickplayBubble } from "./TrickplayBubble"; import { TrickplayBubble } from "./TrickplayBubble";
// Chapter tick height in dp — matches the slider track height for a clean,
// flush look (no top/bottom overflow).
const TICK_HEIGHT = 10;
interface BottomControlsProps { interface BottomControlsProps {
item: BaseItemDto; item: BaseItemDto;
/** Item chapters, used for the tick overlay and chapter list. */
chapters?: ChapterInfo[] | null;
/** Total media duration in milliseconds. */
durationMs: number;
showControls: boolean; showControls: boolean;
isSliding: boolean; isSliding: boolean;
showRemoteBubble: boolean; showRemoteBubble: boolean;
@@ -38,6 +54,8 @@ interface BottomControlsProps {
handleSliderChange: (value: number) => void; handleSliderChange: (value: number) => void;
handleTouchStart: () => void; handleTouchStart: () => void;
handleTouchEnd: () => void; handleTouchEnd: () => void;
/** Programmatic seek (chapter list, hotkeys) — bypasses slide gesture state. */
seekTo: (value: number) => void;
// Trickplay props // Trickplay props
trickPlayUrl: { trickPlayUrl: {
@@ -61,6 +79,8 @@ interface BottomControlsProps {
export const BottomControls: FC<BottomControlsProps> = ({ export const BottomControls: FC<BottomControlsProps> = ({
item, item,
chapters,
durationMs,
showControls, showControls,
isSliding, isSliding,
showRemoteBubble, showRemoteBubble,
@@ -84,12 +104,38 @@ export const BottomControls: FC<BottomControlsProps> = ({
handleSliderChange, handleSliderChange,
handleTouchStart, handleTouchStart,
handleTouchEnd, handleTouchEnd,
seekTo,
trickPlayUrl, trickPlayUrl,
trickplayInfo, trickplayInfo,
time, time,
}) => { }) => {
const { settings } = useSettings(); const { settings } = useSettings();
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [chapterListVisible, setChapterListVisible] = useState(false);
// Only expose chapter UI when there are at least two real markers.
const chapterMarkerList = useMemo(
() => chapterMarkers(chapters, durationMs),
[chapters, durationMs],
);
const hasChapters = chapterMarkerList.length > 1;
// Current chapter name for the always-visible header label (live playback).
const currentChapterName = useMemo(
() => (hasChapters ? chapterNameAt(currentTime, chapters) : null),
[hasChapters, currentTime, chapters],
);
// Chapter name at the scrubbed position for the trickplay bubble. `time` is
// an {h,m,s} object derived from the slider's dragged value — convert back
// to ms for the lookup. Only useful while actively scrubbing.
const scrubChapterName = useMemo(() => {
if (!hasChapters) return null;
const scrubMs =
(time.hours * 3600 + time.minutes * 60 + time.seconds) * 1000;
return chapterNameAt(scrubMs, chapters);
}, [hasChapters, time.hours, time.minutes, time.seconds, chapters]);
return ( return (
<View <View
@@ -131,8 +177,24 @@ export const BottomControls: FC<BottomControlsProps> = ({
{item?.Type === "Audio" && ( {item?.Type === "Audio" && (
<Text className='text-xs opacity-50'>{item?.Album}</Text> <Text className='text-xs opacity-50'>{item?.Album}</Text>
)} )}
{currentChapterName ? (
<Text className='text-xs opacity-70 mt-1' numberOfLines={1}>
{currentChapterName}
</Text>
) : null}
</View> </View>
<View className='flex flex-row space-x-2 shrink-0'> <View className='flex flex-row items-center space-x-2 shrink-0'>
{hasChapters && (
<Pressable
onPress={() => setChapterListVisible(true)}
hitSlop={10}
className='justify-center mr-4'
accessibilityRole='button'
accessibilityLabel={t("chapters.open")}
>
<Ionicons name='bookmarks' size={24} color='white' />
</Pressable>
)}
<SkipButton <SkipButton
showButton={showSkipButton} showButton={showSkipButton}
onPress={skipIntro} onPress={skipIntro}
@@ -176,6 +238,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
height: 10, height: 10,
justifyContent: "center", justifyContent: "center",
alignItems: "stretch", alignItems: "stretch",
// Allow chapter ticks taller than the 10px track to bleed out
// top/bottom (RN defaults to overflow: "hidden" on Android).
overflow: "visible",
}} }}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
@@ -203,6 +268,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
trickPlayUrl={trickPlayUrl} trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo} trickplayInfo={trickplayInfo}
time={time} time={time}
chapterName={scrubChapterName}
/> />
) )
} }
@@ -212,6 +278,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
minimumValue={min} minimumValue={min}
maximumValue={max} maximumValue={max}
/> />
<ChapterTicks markers={chapterMarkerList} height={TICK_HEIGHT} />
</View> </View>
<TimeDisplay <TimeDisplay
currentTime={currentTime} currentTime={currentTime}
@@ -219,6 +286,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
/> />
</View> </View>
</View> </View>
<ChapterList
visible={chapterListVisible}
chapters={chapters}
currentPositionMs={currentTime}
onSeek={seekTo}
onClose={() => setChapterListVisible(false)}
/>
</View> </View>
); );
}; };

View File

@@ -251,6 +251,7 @@ export const Controls: FC<Props> = ({
handleTouchEnd, handleTouchEnd,
handleSliderComplete, handleSliderComplete,
handleSliderChange, handleSliderChange,
seekTo,
} = useVideoSlider({ } = useVideoSlider({
progress, progress,
isSeeking, isSeeking,
@@ -528,6 +529,8 @@ export const Controls: FC<Props> = ({
> >
<BottomControls <BottomControls
item={item} item={item}
chapters={item.Chapters}
durationMs={maxMs}
showControls={showControls} showControls={showControls}
isSliding={isSliding} isSliding={isSliding}
showRemoteBubble={showRemoteBubble} showRemoteBubble={showRemoteBubble}
@@ -551,6 +554,7 @@ export const Controls: FC<Props> = ({
handleSliderChange={handleSliderChange} handleSliderChange={handleSliderChange}
handleTouchStart={handleTouchStart} handleTouchStart={handleTouchStart}
handleTouchEnd={handleTouchEnd} handleTouchEnd={handleTouchEnd}
seekTo={seekTo}
trickPlayUrl={trickPlayUrl} trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo} trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime} time={isSliding || showRemoteBubble ? time : remoteTime}

View File

@@ -22,12 +22,15 @@ interface TrickplayBubbleProps {
minutes: number; minutes: number;
seconds: number; seconds: number;
}; };
/** Chapter name at the scrubbed position, if any. */
chapterName?: string | null;
} }
export const TrickplayBubble: FC<TrickplayBubbleProps> = ({ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
trickPlayUrl, trickPlayUrl,
trickplayInfo, trickplayInfo,
time, time,
chapterName,
}) => { }) => {
if (!trickPlayUrl || !trickplayInfo) { if (!trickPlayUrl || !trickplayInfo) {
return null; return null;
@@ -36,18 +39,30 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
const { x, y, url } = trickPlayUrl; const { x, y, url } = trickPlayUrl;
const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH; const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH;
const tileHeight = tileWidth / trickplayInfo.aspectRatio!; const tileHeight = tileWidth / trickplayInfo.aspectRatio!;
const timeStr = `${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`;
// Slightly larger preview than before (scale 1.6 vs old 1.4) to give the
// overlay text more room and feel closer to the Jellyfin web style.
const previewScale = 1.6;
return ( return (
<View <View
style={{ style={{
position: "absolute", position: "absolute",
left: -62, left: -62,
// Sit just above the slider — high enough not to overlap the
// progress bar, low enough to feel anchored to the thumb.
bottom: 0, bottom: 0,
paddingTop: 30, paddingTop: 12,
paddingBottom: 5, paddingBottom: 5,
width: tileWidth * 1.5, width: tileWidth * 1.5,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
// Bring the bubble in front of the player title / overlays.
zIndex: 999,
elevation: 10,
}} }}
> >
<View <View
@@ -55,7 +70,7 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
width: tileWidth, width: tileWidth,
height: tileHeight, height: tileHeight,
alignSelf: "center", alignSelf: "center",
transform: [{ scale: 1.4 }], transform: [{ scale: previewScale }],
borderRadius: 5, borderRadius: 5,
}} }}
className='bg-neutral-800 overflow-hidden' className='bg-neutral-800 overflow-hidden'
@@ -75,17 +90,51 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
source={{ uri: url }} source={{ uri: url }}
contentFit='cover' contentFit='cover'
/> />
{/*
* Bottom-right overlay (Jellyfin web style) — chapter name (small,
* faded) above the timestamp (small, bold). Sits on top of the
* trickplay frame inside the same overflow:hidden container so it
* always stays within the bubble bounds.
*/}
<View
pointerEvents='none'
style={{
position: "absolute",
left: 4,
bottom: 3,
alignItems: "flex-start",
paddingHorizontal: 3,
paddingVertical: 1,
borderRadius: 3,
backgroundColor: "rgba(0,0,0,0.55)",
maxWidth: tileWidth - 8,
}}
>
{chapterName ? (
<Text
numberOfLines={1}
style={{
color: "#fff",
fontSize: 7,
opacity: 0.85,
lineHeight: 9,
}}
>
{chapterName}
</Text>
) : null}
<Text
style={{
color: "#fff",
fontSize: 8,
fontWeight: "600",
lineHeight: 10,
}}
>
{timeStr}
</Text>
</View>
</View> </View>
<Text
style={{
marginTop: 30,
fontSize: 16,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
</Text>
</View> </View>
); );
}; };

View File

@@ -74,6 +74,21 @@ export function useVideoSlider({
[seek, play, progress, isSeeking], [seek, play, progress, isSeeking],
); );
// Programmatic seek (chapter list, hotkeys) that bypasses the slide gesture.
// Reads `isPlaying` directly instead of `wasPlayingRef`, which is only set
// during a real slide and would carry stale state on a tap-to-seek.
const seekTo = useCallback(
(value: number) => {
const seekValue = Math.max(0, Math.floor(value));
progress.value = seekValue;
seek(seekValue);
if (isPlaying) {
play();
}
},
[seek, play, progress, isPlaying],
);
const handleSliderChange = useCallback( const handleSliderChange = useCallback(
debounce((value: number) => { debounce((value: number) => {
// Convert ms to ticks for trickplay // Convert ms to ticks for trickplay
@@ -96,5 +111,6 @@ export function useVideoSlider({
handleTouchEnd, handleTouchEnd,
handleSliderComplete, handleSliderComplete,
handleSliderChange, handleSliderChange,
seekTo,
}; };
} }

View File

@@ -12,11 +12,17 @@ export const excludeFields = (fieldsToExclude: ItemFields[]) => {
); );
}; };
type ExtraQueryOptions = {
gcTime?: number;
staleTime?: number;
};
export const useItemQuery = ( export const useItemQuery = (
itemId: string | undefined, itemId: string | undefined,
isOffline?: boolean, isOffline?: boolean,
fields?: ItemFields[], fields?: ItemFields[],
excludeFields?: ItemFields[], excludeFields?: ItemFields[],
queryOptions?: ExtraQueryOptions,
) => { ) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -53,5 +59,6 @@ export const useItemQuery = (
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
refetchOnReconnect: true, refetchOnReconnect: true,
networkMode: "always", networkMode: "always",
...queryOptions,
}); });
}; };

View File

@@ -177,6 +177,9 @@ export const useAddToWatchlist = () => {
} }
}, },
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: ["streamystats", "watchlists"],
});
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["streamystats", "watchlist", variables.watchlistId], queryKey: ["streamystats", "watchlist", variables.watchlistId],
}); });
@@ -235,6 +238,9 @@ export const useRemoveFromWatchlist = () => {
} }
}, },
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: ["streamystats", "watchlists"],
});
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["streamystats", "watchlist", variables.watchlistId], queryKey: ["streamystats", "watchlist", variables.watchlistId],
}); });

View File

@@ -220,20 +220,27 @@ final class MPVLayerRenderer {
statusObservation?.invalidate() statusObservation?.invalidate()
statusObservation = nil statusObservation = nil
queue.sync { [weak self] in if let handle = self.mpv {
guard let self, let handle = self.mpv else { return }
mpv_set_wakeup_callback(handle, nil, nil)
mpv_terminate_destroy(handle)
self.mpv = nil self.mpv = nil
// Remove the wakeup callback synchronously while `self` is still
// alive so it can never fire against a deallocated instance.
mpv_set_wakeup_callback(handle, nil, nil)
// Destroy mpv OFF the main thread. mpv_terminate_destroy() blocks
// until all mpv threads (including the vo_avfoundation output thread)
// are joined, and that teardown needs the main run loop to finish.
// Calling it via queue.sync from deinit (main thread) deadlocks/freezes
// the UI. queue.async only references `handle`, never `self`.
queue.async {
mpv_terminate_destroy(handle)
}
} }
DispatchQueue.main.async { [weak self] in let layer = self.displayLayer
guard let self else { return } DispatchQueue.main.async {
if #available(iOS 18.0, *) { if #available(iOS 18.0, *) {
self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil) layer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil)
} else { } else {
self.displayLayer.flushAndRemoveImage() layer.flushAndRemoveImage()
} }
} }

View File

@@ -1,32 +1,19 @@
Pod::Spec.new do |s| Pod::Spec.new do |s|
s.name = 'MpvPlayer' s.name = 'MpvPlayer'
s.version = '1.0.0' s.version = '1.0.0'
s.summary = 'MPVKit for Expo' s.summary = 'MPV-based video player for Streamyfin (Expo module)'
s.description = 'MPVKit for Expo' s.author = 'Streamyfin'
s.author = 'mpvkit' s.homepage = 'https://github.com/streamyfin/streamyfin'
s.homepage = 'https://github.com/mpvkit/MPVKit' s.platforms = { :ios => '15.1', :tvos => '15.1' }
s.platforms = { s.source = { git: '' }
:ios => '15.1',
:tvos => '15.1'
}
s.source = { git: 'https://github.com/mpvkit/MPVKit.git' }
s.static_framework = true s.static_framework = true
s.dependency 'ExpoModulesCore' s.dependency 'ExpoModulesCore'
s.dependency 'MPVKit-GPL' s.dependency 'MPVKit'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = { s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES', 'DEFINES_MODULE' => 'YES',
'VALID_ARCHS' => 'arm64', 'SWIFT_COMPILATION_MODE' => 'wholemodule'
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
'STRIP_INSTALLED_PRODUCT' => 'YES',
'DEPLOYMENT_POSTPROCESSING' => 'YES',
}
s.user_target_xcconfig = {
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386'
} }
s.source_files = "*.{h,m,mm,swift,hpp,cpp}" s.source_files = "*.{h,m,mm,swift,hpp,cpp}"

View File

@@ -150,6 +150,16 @@ final class PiPController: NSObject {
CMTimebaseSetRate(tb, rate: Float64(rate)) CMTimebaseSetRate(tb, rate: Float64(rate))
} }
} }
deinit {
if let tb = timebase {
CMTimebaseSetRate(tb, rate: 0)
}
sampleBufferDisplayLayer?.controlTimebase = nil
timebase = nil
pipController?.delegate = nil
pipController = nil
}
} }
// MARK: - AVPictureInPictureControllerDelegate // MARK: - AVPictureInPictureControllerDelegate

View File

@@ -15,6 +15,7 @@
"android:tv": "cross-env EXPO_TV=1 expo run:android", "android:tv": "cross-env EXPO_TV=1 expo run:android",
"build:android:local": "cd android && cross-env NODE_ENV=production ./gradlew assembleRelease", "build:android:local": "cd android && cross-env NODE_ENV=production ./gradlew assembleRelease",
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production", "ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
"prepare": "husky", "prepare": "husky",
"typecheck": "node scripts/typecheck.js", "typecheck": "node scripts/typecheck.js",
"check": "biome check . --max-diagnostics 1000", "check": "biome check . --max-diagnostics 1000",
@@ -33,7 +34,7 @@
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@gorhom/bottom-sheet": "5.2.8", "@gorhom/bottom-sheet": "5.2.8",
"@jellyfin/sdk": "^0.13.0", "@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^11.4.1", "@react-native-community/netinfo": "^12.0.0",
"@react-navigation/material-top-tabs": "7.4.9", "@react-navigation/material-top-tabs": "7.4.9",
"@react-navigation/native": "^7.0.14", "@react-navigation/native": "^7.0.14",
"@shopify/flash-list": "2.0.2", "@shopify/flash-list": "2.0.2",
@@ -70,14 +71,14 @@
"expo-system-ui": "~6.0.9", "expo-system-ui": "~6.0.9",
"expo-task-manager": "14.0.9", "expo-task-manager": "14.0.9",
"expo-web-browser": "~15.0.10", "expo-web-browser": "~15.0.10",
"i18next": "^25.0.0", "i18next": "^26.0.0",
"jotai": "2.16.2", "jotai": "2.16.2",
"lodash": "4.17.23", "lodash": "4.17.23",
"nativewind": "^2.0.11", "nativewind": "^2.0.11",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-i18next": "16.5.4", "react-i18next": "17.0.8",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-awesome-slider": "^2.9.0", "react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "1.1.0", "react-native-bottom-tabs": "1.1.0",
@@ -94,7 +95,7 @@
"react-native-ios-context-menu": "^3.2.1", "react-native-ios-context-menu": "^3.2.1",
"react-native-ios-utilities": "5.2.0", "react-native-ios-utilities": "5.2.0",
"react-native-mmkv": "4.1.1", "react-native-mmkv": "4.1.1",
"react-native-nitro-modules": "0.33.9", "react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "^6.9.1", "react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-reanimated-carousel": "4.0.3", "react-native-reanimated-carousel": "4.0.3",
@@ -117,16 +118,16 @@
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.6", "@babel/core": "7.28.6",
"@biomejs/biome": "2.3.11", "@biomejs/biome": "2.3.11",
"@react-native-community/cli": "20.1.1", "@react-native-community/cli": "20.1.3",
"@react-native-tvos/config-tv": "0.1.4", "@react-native-tvos/config-tv": "0.1.6",
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
"@types/lodash": "4.17.23", "@types/lodash": "4.17.23",
"@types/react": "19.1.17", "@types/react": "19.1.17",
"@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.17.14", "expo-doctor": "1.19.7",
"husky": "9.1.7", "husky": "9.1.7",
"lint-staged": "16.2.7", "lint-staged": "17.0.5",
"react-test-renderer": "19.2.3", "react-test-renderer": "19.2.3",
"typescript": "5.9.3" "typescript": "5.9.3"
}, },

View File

@@ -610,6 +610,12 @@
"downloaded_file_no": "No", "downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel" "downloaded_file_cancel": "Cancel"
}, },
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": { "item_card": {
"next_up": "Next Up", "next_up": "Next Up",
"no_items_to_display": "No Items to Display", "no_items_to_display": "No Items to Display",

View File

@@ -341,6 +341,14 @@ export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
loadPluginSettings(), loadPluginSettings(),
); );
const hasMeaningfulSettingValue = (value: unknown) =>
value !== undefined && value !== null && value !== "";
const getEffectiveSettingValue = <K extends keyof Settings>(
settings: Partial<Settings> | null | undefined,
settingsKey: K,
) => settings?.[settingsKey] ?? defaultValues[settingsKey];
export const useSettings = () => { export const useSettings = () => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const [_settings, setSettings] = useAtom(settingsAtom); const [_settings, setSettings] = useAtom(settingsAtom);
@@ -381,12 +389,13 @@ export const useSettings = () => {
for (const [key, setting] of Object.entries(newPluginSettings)) { for (const [key, setting] of Object.entries(newPluginSettings)) {
if (setting && !setting.locked && setting.value !== undefined) { if (setting && !setting.locked && setting.value !== undefined) {
const settingsKey = key as keyof Settings; const settingsKey = key as keyof Settings;
// Apply if forceOverride is true, or if user hasn't explicitly set this value const effectiveValue = getEffectiveSettingValue(
if ( _settings,
forceOverride || settingsKey,
_settings[settingsKey] === undefined || );
_settings[settingsKey] === "" // Apply if forceOverride is true, or if neither persisted settings
) { // nor app defaults provide a meaningful value.
if (forceOverride || !hasMeaningfulSettingValue(effectiveValue)) {
(updates as any)[settingsKey] = setting.value; (updates as any)[settingsKey] = setting.value;
} }
} }
@@ -438,28 +447,22 @@ export const useSettings = () => {
// We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting. // We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting.
// If admin sets locked to false but provides a value, // If admin sets locked to false but provides a value,
// use user settings first and fallback on admin setting if required. // use persisted settings first, then app defaults, and only fallback on the
// plugin value when neither provides a meaningful value.
const settings: Settings = useMemo(() => { const settings: Settings = useMemo(() => {
const unlockedPluginDefaults: Partial<Settings> = {};
const overrideSettings = Object.entries(pluginSettings ?? {}).reduce< const overrideSettings = Object.entries(pluginSettings ?? {}).reduce<
Partial<Settings> Partial<Settings>
>((acc, [key, setting]) => { >((acc, [key, setting]) => {
if (setting) { if (setting) {
const { value, locked } = setting; const { value, locked } = setting;
const settingsKey = key as keyof Settings; const settingsKey = key as keyof Settings;
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
// Make sure we override default settings with plugin settings when they are not locked.
if (
!locked &&
value !== undefined &&
_settings?.[settingsKey] !== value
) {
(unlockedPluginDefaults as any)[settingsKey] = value;
}
(acc as any)[settingsKey] = locked (acc as any)[settingsKey] = locked
? value ? value
: (_settings?.[settingsKey] ?? value); : hasMeaningfulSettingValue(effectiveValue)
? effectiveValue
: value;
} }
return acc; return acc;
}, {}); }, {});

138
utils/chapters.test.ts Normal file
View File

@@ -0,0 +1,138 @@
import { describe, expect, test } from "bun:test";
import {
chapterMarkers,
chapterNameAt,
chapterStartsMs,
currentChapterIndex,
formatChapterTime,
sortedChapters,
} from "./chapters";
// Helper: a ChapterInfo with a start in milliseconds.
const ch = (ms: number, name?: string) => ({
StartPositionTicks: ms * 10000,
Name: name,
});
describe("chapterMarkers", () => {
test("maps chapters to position + percent", () => {
expect(chapterMarkers([ch(0), ch(30_000), ch(60_000)], 120_000)).toEqual([
{ positionMs: 0, percent: 0 },
{ positionMs: 30_000, percent: 25 },
{ positionMs: 60_000, percent: 50 },
]);
});
test("drops chapters past the duration", () => {
expect(chapterMarkers([ch(0), ch(200_000)], 120_000)).toEqual([
{ positionMs: 0, percent: 0 },
]);
});
test("returns [] when duration is 0 or chapters missing", () => {
expect(chapterMarkers([ch(0)], 0)).toEqual([]);
expect(chapterMarkers(null, 120_000)).toEqual([]);
expect(chapterMarkers(undefined, 120_000)).toEqual([]);
});
test("excludes a chapter exactly at the duration", () => {
expect(chapterMarkers([ch(0), ch(120_000)], 120_000)).toEqual([
{ positionMs: 0, percent: 0 },
]);
});
test("skips chapters with no StartPositionTicks", () => {
expect(
chapterMarkers([{ StartPositionTicks: undefined }, ch(30_000)], 120_000),
).toEqual([{ positionMs: 30_000, percent: 25 }]);
});
});
describe("currentChapterIndex", () => {
const chapters = [ch(0), ch(30_000), ch(60_000)];
test("returns the chapter containing the position", () => {
expect(currentChapterIndex(0, chapters)).toBe(0);
expect(currentChapterIndex(15_000, chapters)).toBe(0);
expect(currentChapterIndex(30_000, chapters)).toBe(1);
expect(currentChapterIndex(90_000, chapters)).toBe(2);
});
test("returns -1 before the first chapter and for no chapters", () => {
expect(currentChapterIndex(-5, chapters)).toBe(-1);
expect(currentChapterIndex(10_000, [])).toBe(-1);
expect(currentChapterIndex(10_000, null)).toBe(-1);
});
});
describe("sortedChapters", () => {
test("pairs each chapter with its ms start, sorted ascending", () => {
const a = ch(60_000, "C");
const b = ch(0, "A");
const c = ch(30_000, "B");
expect(sortedChapters([a, b, c])).toEqual([
{ chapter: b, positionMs: 0 },
{ chapter: c, positionMs: 30_000 },
{ chapter: a, positionMs: 60_000 },
]);
});
test("returns [] for null/undefined", () => {
expect(sortedChapters(null)).toEqual([]);
expect(sortedChapters(undefined)).toEqual([]);
});
});
describe("chapterStartsMs", () => {
test("returns sorted ms positions", () => {
expect(chapterStartsMs([ch(60_000), ch(0), ch(30_000)])).toEqual([
0, 30_000, 60_000,
]);
});
test("skips entries without StartPositionTicks", () => {
expect(
chapterStartsMs([ch(30_000), { StartPositionTicks: undefined }, ch(0)]),
).toEqual([0, 30_000]);
});
test("returns [] for null/undefined/empty", () => {
expect(chapterStartsMs(null)).toEqual([]);
expect(chapterStartsMs(undefined)).toEqual([]);
expect(chapterStartsMs([])).toEqual([]);
});
});
describe("chapterNameAt", () => {
const named = [
{ StartPositionTicks: 0, Name: "Intro" },
{ StartPositionTicks: 30_000 * 10000, Name: "Action" },
{ StartPositionTicks: 60_000 * 10000, Name: "Outro" },
];
test("returns the chapter name for the active position", () => {
expect(chapterNameAt(0, named)).toBe("Intro");
expect(chapterNameAt(15_000, named)).toBe("Intro");
expect(chapterNameAt(45_000, named)).toBe("Action");
expect(chapterNameAt(90_000, named)).toBe("Outro");
});
test("returns null before the first chapter", () => {
expect(chapterNameAt(-1, named)).toBeNull();
});
test("returns null for null/undefined/empty chapters", () => {
expect(chapterNameAt(10_000, null)).toBeNull();
expect(chapterNameAt(10_000, undefined)).toBeNull();
expect(chapterNameAt(10_000, [])).toBeNull();
});
test("returns null when the active chapter has no Name", () => {
expect(chapterNameAt(15_000, [ch(0), ch(30_000)])).toBeNull();
});
});
describe("formatChapterTime", () => {
test("formats m:ss and h:mm:ss", () => {
expect(formatChapterTime(65_000)).toBe("1:05");
expect(formatChapterTime(3_725_000)).toBe("1:02:05");
expect(formatChapterTime(-100)).toBe("0:00");
});
});

97
utils/chapters.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* Pure helpers for Jellyfin chapter markers. Dependency-free so they are
* unit-testable under `bun test`.
*/
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { ticksToMs } from "@/utils/time";
export interface ChapterMarker {
/** Chapter start, in milliseconds. */
positionMs: number;
/** Chapter start as a percentage (0-100) of the media duration. */
percent: number;
}
export interface ChapterEntry {
chapter: ChapterInfo;
/** Chapter start, in milliseconds. */
positionMs: number;
}
/** Chapters paired with their millisecond start, sorted ascending by start. */
export const sortedChapters = (
chapters: ChapterInfo[] | null | undefined,
): ChapterEntry[] =>
(chapters ?? [])
.filter((c) => c.StartPositionTicks != null)
.map((chapter) => ({
chapter,
positionMs: ticksToMs(chapter.StartPositionTicks),
}))
.sort((a, b) => a.positionMs - b.positionMs);
/** Chapter start positions in milliseconds, ascending. */
export const chapterStartsMs = (
chapters: ChapterInfo[] | null | undefined,
): number[] =>
(chapters ?? [])
.filter((c) => c.StartPositionTicks != null)
.map((c) => ticksToMs(c.StartPositionTicks))
.sort((a, b) => a - b);
/** Chapter markers within [0, durationMs]; empty when duration is unknown. */
export const chapterMarkers = (
chapters: ChapterInfo[] | null | undefined,
durationMs: number,
): ChapterMarker[] => {
if (durationMs <= 0) return [];
return chapterStartsMs(chapters)
.filter((ms) => ms >= 0 && ms < durationMs)
.map((ms) => ({ positionMs: ms, percent: (ms / durationMs) * 100 }));
};
/** Index of the chapter containing `positionMs`, or -1 if before the first. */
export const currentChapterIndex = (
positionMs: number,
chapters: ChapterInfo[] | null | undefined,
): number => {
const starts = chapterStartsMs(chapters);
let index = -1;
for (let i = 0; i < starts.length; i++) {
if (positionMs >= starts[i]) index = i;
else break;
}
return index;
};
/** Name of the chapter containing `positionMs`, or null if none / unnamed. */
export const chapterNameAt = (
positionMs: number,
chapters: ChapterInfo[] | null | undefined,
): string | null => {
// Sort once, derive both the active index and the entry from the same array
// — `chapterNameAt` runs on every playback tick, so paying for one `sort()`
// instead of two is worth the duplication of the index loop here.
const sorted = sortedChapters(chapters);
let idx = -1;
for (let i = 0; i < sorted.length; i++) {
if (positionMs >= sorted[i].positionMs) idx = i;
else break;
}
if (idx < 0) return null;
const name = sorted[idx]?.chapter.Name;
return name && name.length > 0 ? name : null;
};
/** `m:ss` (or `h:mm:ss` past an hour) label for a millisecond position. */
export const formatChapterTime = (positionMs: number): string => {
const total = Math.max(0, Math.floor(positionMs / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
const pad = (n: number) => String(n).padStart(2, "0");
return hours > 0
? `${hours}:${pad(minutes)}:${pad(seconds)}`
: `${minutes}:${pad(seconds)}`;
};