mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-12 08:50:25 +01:00
Compare commits
6 Commits
refactor/e
...
ci/auto-up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c1900a27d | ||
|
|
888b8bb342 | ||
|
|
1c8a0ac35e | ||
|
|
e4def1f2a1 | ||
|
|
55376cd824 | ||
|
|
3c8369ea4d |
5
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
5
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -75,10 +75,13 @@ body:
|
|||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: Streamyfin Version
|
label: Streamyfin Version
|
||||||
description: What version of Streamyfin are you using?
|
description: What version of Streamyfin are you running? On a TestFlight or development build, choose "TestFlight/Development build" and include the exact version string shown in the app's Settings.
|
||||||
options:
|
options:
|
||||||
- 0.54.1
|
- 0.54.1
|
||||||
- 0.51.0
|
- 0.51.0
|
||||||
|
- 0.47.1
|
||||||
|
- 0.30.2
|
||||||
|
- 0.28.0
|
||||||
- Older
|
- Older
|
||||||
- TestFlight/Development build
|
- TestFlight/Development build
|
||||||
validations:
|
validations:
|
||||||
|
|||||||
13
.github/workflows/build-apps.yml
vendored
13
.github/workflows/build-apps.yml
vendored
@@ -11,14 +11,11 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [develop, master]
|
branches: [develop, master]
|
||||||
|
|
||||||
# Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the
|
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
|
||||||
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
|
# branch + commit a CI build was made from. EAS cloud builds use EAS_BUILD_* instead.
|
||||||
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
|
|
||||||
# run (artifacts + logs) without needing Expo access.
|
|
||||||
env:
|
env:
|
||||||
EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
|
EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||||
EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
|
EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
|
||||||
EXPO_PUBLIC_GIT_RUN_NUMBER: ${{ github.run_number }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-android-phone:
|
build-android-phone:
|
||||||
@@ -240,9 +237,7 @@ jobs:
|
|||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 0
|
EXPO_TV: 0
|
||||||
# `ci` profile (extends production, autoIncrement off): keeps CI builds out of
|
run: eas build -p ios --local --non-interactive
|
||||||
# the production version tier and stops them inflating the store build counter.
|
|
||||||
run: eas build -p ios --local --non-interactive --profile ci
|
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
- name: 📅 Set date tag
|
||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
@@ -367,7 +362,7 @@ jobs:
|
|||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 1
|
EXPO_TV: 1
|
||||||
run: eas build -p ios --local --non-interactive --profile ci_tv
|
run: eas build -p ios --local --non-interactive
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
- name: 📅 Set date tag
|
||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|||||||
2
.github/workflows/conflict.yml
vendored
2
.github/workflows/conflict.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: 🚩 Apply merge conflict label
|
- name: 🚩 Apply merge conflict label
|
||||||
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||||
with:
|
with:
|
||||||
dirtyLabel: '⚔️ merge-conflict'
|
dirtyLabel: '⚔️ merge-conflict'
|
||||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||||
|
|||||||
121
.github/workflows/update-issue-form.yml
vendored
121
.github/workflows/update-issue-form.yml
vendored
@@ -1,67 +1,102 @@
|
|||||||
name: 🐛 Update Bug Report Template
|
name: 🐛 Update Issue Form Versions
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published] # Run on every published release on any branch
|
# Only full releases populate the dropdown (no drafts/prereleases).
|
||||||
|
types: [released]
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * 1" # Weekly safety net (Mondays 03:00 UTC) in case a release event was missed
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Fixed group so a release event and the weekly cron can't race on the same
|
||||||
|
# ci/update-issue-form branch — runs queue instead of force-pushing over each other.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: update-issue-form-${{ github.event.release.tag_name || github.run_id }}
|
group: update-issue-form
|
||||||
cancel-in-progress: true
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-bug-report:
|
update-issue-form:
|
||||||
|
name: 🔢 Populate version dropdown
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
issues: write
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
# On `release` events GITHUB_SHA is the tagged commit — without this the
|
||||||
cache: 'npm'
|
# script would regenerate the form from the tag's (stale) copy and the bot
|
||||||
|
# PR would revert any form edits made on develop since that release.
|
||||||
|
ref: develop
|
||||||
|
|
||||||
- name: 🔍 Extract minor version from app.json
|
- name: 🍞 Setup Bun
|
||||||
id: minor
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
|
|
||||||
with:
|
with:
|
||||||
result-encoding: string
|
bun-version: latest
|
||||||
script: |
|
|
||||||
const fs = require('fs-extra');
|
|
||||||
const semver = require('semver');
|
|
||||||
const content = fs.readJsonSync('./app.json');
|
|
||||||
const version = content.expo.version;
|
|
||||||
const minorVersion = semver.minor(version);
|
|
||||||
return minorVersion.toString();
|
|
||||||
|
|
||||||
- name: 📝 Update bug report version
|
- name: 🔢 Populate version dropdown from GitHub releases
|
||||||
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
|
id: populate
|
||||||
with:
|
run: bun scripts/update-issue-form.mjs
|
||||||
semver: '^0.${{ steps.minor.outputs.result }}.0'
|
env:
|
||||||
dry_run: no-push
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
|
|
||||||
- name: ⚙️ Update bug report node version dropdown
|
- name: 📬 Create pull request
|
||||||
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
|
id: cpr
|
||||||
with:
|
|
||||||
dropdown: _node_version
|
|
||||||
package: node
|
|
||||||
semver: '>=24.0.0'
|
|
||||||
dry_run: no-push
|
|
||||||
|
|
||||||
- name: 📬 Commit and create pull request
|
|
||||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||||
with:
|
with:
|
||||||
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
add-paths: .github/ISSUE_TEMPLATE/issue_report.yml
|
||||||
branch: ci-update-bug-report
|
branch: ci/update-issue-form
|
||||||
base: develop
|
base: develop
|
||||||
delete-branch: true
|
delete-branch: true
|
||||||
labels: ⚙️ ci, 🤖 github-actions
|
labels: ⚙️ ci, 🤖 github-actions
|
||||||
title: 'chore(): Update bug report template to match release version'
|
commit-message: "chore: update issue form version dropdown"
|
||||||
|
title: "chore: update issue form version dropdown"
|
||||||
|
# Follows .github/pull_request_template.md so the bot PR isn't flagged by PR validation.
|
||||||
body: |
|
body: |
|
||||||
Automated update to `.github/ISSUE_TEMPLATE/bug_report.yml`
|
# 📦 Pull Request
|
||||||
Triggered by workflow run [${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
|
||||||
|
## 📝 Description
|
||||||
|
|
||||||
|
Automated update of the **Streamyfin Version** dropdown in `.github/ISSUE_TEMPLATE/issue_report.yml`, populated from the latest published GitHub releases by `scripts/update-issue-form.mjs`.
|
||||||
|
|
||||||
|
**Version dropdown now lists:** ${{ steps.populate.outputs.versions }}
|
||||||
|
|
||||||
|
Triggered by `${{ github.event_name }}`${{ github.event.release.tag_name && format(' — release {0}', github.event.release.tag_name) || '' }} · [run ${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||||
|
|
||||||
|
## 🏷️ Ticket / Issue
|
||||||
|
|
||||||
|
N/A — automated maintenance.
|
||||||
|
|
||||||
|
### 🖼️ Screenshots / GIFs (if UI)
|
||||||
|
|
||||||
|
N/A — issue-template metadata only, no app UI.
|
||||||
|
|
||||||
|
## ✅ Checklist
|
||||||
|
|
||||||
|
- [x] I’ve read the [contribution guidelines](CONTRIBUTING.md)
|
||||||
|
- [x] Verified that changes behave as expected for all platforms
|
||||||
|
- [x] Code passes lint/formatting and type checks (`tsc`/`biome`)
|
||||||
|
- [x] No secrets, hardcoded credentials, or private config files are included
|
||||||
|
- [x] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
|
||||||
|
|
||||||
|
## 🔍 Testing Instructions
|
||||||
|
|
||||||
|
N/A — generated by CI from published releases; review the dropdown diff in `issue_report.yml`.
|
||||||
|
|
||||||
|
- name: 🔀 Enable auto-merge
|
||||||
|
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
# Known limitation: PRs created with GITHUB_TOKEN don't trigger CI workflows
|
||||||
|
# (GitHub anti-recursion), so the required checks stay "Expected" until a
|
||||||
|
# maintainer kicks them (close/reopen the PR, or push an empty commit).
|
||||||
|
# Auto-merge is still worth enabling: once checks run and reviews land,
|
||||||
|
# the PR merges itself.
|
||||||
|
run: |
|
||||||
|
gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}" \
|
||||||
|
|| echo "::warning::Could not enable auto-merge — enable 'Allow auto-merge' in repo settings (and branch protection); merge the PR manually for now."
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
// Registers the tsx require hook so the TypeScript config plugins referenced
|
const { execFileSync } = require("node:child_process");
|
||||||
// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation.
|
|
||||||
import "tsx/cjs";
|
|
||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import type { ConfigContext, ExpoConfig } from "expo/config";
|
|
||||||
|
|
||||||
// Build metadata, injected into `extra.build` and read at runtime via
|
// Build metadata, injected into `extra.build` and read at runtime via
|
||||||
// expo-constants (see utils/version.ts). Sources in priority order:
|
// expo-constants (see utils/version.ts). Sources in priority order:
|
||||||
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
||||||
const git = (args: string[]): string | null => {
|
const git = (args) => {
|
||||||
try {
|
try {
|
||||||
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
||||||
.toString()
|
.toString()
|
||||||
@@ -37,25 +33,19 @@ const buildMeta = {
|
|||||||
process.env.EAS_BUILD_PROFILE ||
|
process.env.EAS_BUILD_PROFILE ||
|
||||||
process.env.EXPO_PUBLIC_BUILD_PROFILE ||
|
process.env.EXPO_PUBLIC_BUILD_PROFILE ||
|
||||||
null,
|
null,
|
||||||
// GitHub Actions run number (#2098) — lets anyone map a sideloaded CI build back
|
|
||||||
// to its Actions run (artifacts + logs) without Expo access. Null outside CI.
|
|
||||||
runNumber:
|
|
||||||
process.env.GITHUB_RUN_NUMBER ||
|
|
||||||
process.env.EXPO_PUBLIC_GIT_RUN_NUMBER ||
|
|
||||||
null,
|
|
||||||
builtAt: new Date().toISOString(),
|
builtAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ({ config }: ConfigContext): ExpoConfig => {
|
module.exports = ({ config }) => {
|
||||||
if (process.env.EXPO_TV !== "1") {
|
if (process.env.EXPO_TV !== "1") {
|
||||||
config.plugins?.push("expo-background-task");
|
config.plugins.push("expo-background-task");
|
||||||
|
|
||||||
config.plugins?.push([
|
config.plugins.push([
|
||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
config.plugins?.push([
|
config.plugins.push([
|
||||||
"expo-camera",
|
"expo-camera",
|
||||||
{
|
{
|
||||||
cameraPermission:
|
cameraPermission:
|
||||||
@@ -65,7 +55,7 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only override googleServicesFile if env var is set
|
// Only override googleServicesFile if env var is set
|
||||||
const androidConfig: { googleServicesFile?: string } = {};
|
const androidConfig = {};
|
||||||
if (process.env.GOOGLE_SERVICES_JSON) {
|
if (process.env.GOOGLE_SERVICES_JSON) {
|
||||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||||
}
|
}
|
||||||
@@ -75,5 +65,5 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
|||||||
return {
|
return {
|
||||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||||
...config,
|
...config,
|
||||||
} as ExpoConfig;
|
};
|
||||||
};
|
};
|
||||||
24
app.json
24
app.json
@@ -71,8 +71,8 @@
|
|||||||
],
|
],
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"./plugins/withExcludeMedia3Dash.ts",
|
"./plugins/withExcludeMedia3Dash.js",
|
||||||
"./plugins/withTVUserManagement.ts",
|
"./plugins/withTVUserManagement.js",
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
@@ -131,17 +131,17 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-web-browser",
|
"expo-web-browser",
|
||||||
["./plugins/with-runtime-framework-headers.ts"],
|
["./plugins/with-runtime-framework-headers.js"],
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.ts"],
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
["./plugins/withAndroidAlertColors.ts"],
|
["./plugins/withAndroidAlertColors.js"],
|
||||||
["./plugins/withAndroidManifest.ts"],
|
["./plugins/withAndroidManifest.js"],
|
||||||
["./plugins/withTrustLocalCerts.ts"],
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
["./plugins/withGradleProperties.ts"],
|
["./plugins/withGradleProperties.js"],
|
||||||
["./plugins/withTVOSAppIcon.ts"],
|
["./plugins/withTVOSAppIcon.js"],
|
||||||
["./plugins/withTVOSTopShelf.ts"],
|
["./plugins/withTVOSTopShelf.js"],
|
||||||
["./plugins/withTVXcodeEnv.ts"],
|
["./plugins/withTVXcodeEnv.js"],
|
||||||
[
|
[
|
||||||
"./plugins/withGitPod.ts",
|
"./plugins/withGitPod.js",
|
||||||
{
|
{
|
||||||
"podName": "MPVKit",
|
"podName": "MPVKit",
|
||||||
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
||||||
|
|||||||
57
bun.lock
57
bun.lock
@@ -112,7 +112,6 @@
|
|||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "17.0.5",
|
||||||
"react-test-renderer": "19.2.3",
|
"react-test-renderer": "19.2.3",
|
||||||
"tsx": "^4.22.4",
|
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -294,58 +293,6 @@
|
|||||||
|
|
||||||
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
|
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
|
|
||||||
|
|
||||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
|
|
||||||
|
|
||||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="],
|
|
||||||
|
|
||||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="],
|
|
||||||
|
|
||||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="],
|
|
||||||
|
|
||||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="],
|
|
||||||
|
|
||||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="],
|
|
||||||
|
|
||||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="],
|
|
||||||
|
|
||||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="],
|
|
||||||
|
|
||||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="],
|
|
||||||
|
|
||||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="],
|
|
||||||
|
|
||||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="],
|
|
||||||
|
|
||||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="],
|
|
||||||
|
|
||||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="],
|
|
||||||
|
|
||||||
"@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="],
|
"@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="],
|
||||||
|
|
||||||
"@expo/cli": ["@expo/cli@56.1.12", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.8", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.10", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.12", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.13", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.0", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.13", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.12", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.4", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-Ya/13E1yDx1oAuPw5MDmqzIGyzwSs7KSr1EjgSObOF0VO0GD9jqJjvjOiwurjScLUfxcGZQgq23UzMlBVHwdvA=="],
|
"@expo/cli": ["@expo/cli@56.1.12", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.8", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.10", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.12", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.13", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.0", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.13", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.12", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.4", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-Ya/13E1yDx1oAuPw5MDmqzIGyzwSs7KSr1EjgSObOF0VO0GD9jqJjvjOiwurjScLUfxcGZQgq23UzMlBVHwdvA=="],
|
||||||
@@ -970,8 +917,6 @@
|
|||||||
|
|
||||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
|
|
||||||
|
|
||||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||||
@@ -1874,8 +1819,6 @@
|
|||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
|
|
||||||
|
|
||||||
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
|
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
|
||||||
|
|
||||||
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, TextInput, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
||||||
@@ -231,48 +231,26 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
paddingTop: insets.top + TOP_PADDING,
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
|
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
|
||||||
TextInput fallback on Android TV (the native module is Apple-only). */}
|
module). It renders the native search bar + grid keyboard and
|
||||||
{Platform.OS === "ios" ? (
|
forwards typed text into the existing query pipeline via setSearch;
|
||||||
<View
|
our own results grid renders below. */}
|
||||||
style={{
|
{/* No horizontal margin here: the native tvOS search bar centers itself
|
||||||
marginBottom: 24,
|
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
||||||
height: SEARCH_AREA_HEIGHT,
|
margins squeeze the bar's width and clip that trailing hint, so let
|
||||||
}}
|
the native view span the full width and own its own insets. */}
|
||||||
>
|
<View
|
||||||
{/* No horizontal margin here: the native tvOS search bar centers
|
style={{
|
||||||
itself and renders a trailing "Hold to Dictate" hint. */}
|
marginBottom: 24,
|
||||||
<TvSearchView
|
height: SEARCH_AREA_HEIGHT,
|
||||||
style={{ width: "100%", height: "100%" }}
|
}}
|
||||||
placeholder={t("search.search")}
|
>
|
||||||
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
<TvSearchView
|
||||||
/>
|
style={{ width: "100%", height: "100%" }}
|
||||||
</View>
|
placeholder={t("search.search")}
|
||||||
) : (
|
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
||||||
<View
|
/>
|
||||||
style={{
|
</View>
|
||||||
marginHorizontal: HORIZONTAL_PADDING,
|
|
||||||
marginBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextInput
|
|
||||||
style={{
|
|
||||||
height: 56,
|
|
||||||
width: "100%",
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
fontSize: 28,
|
|
||||||
color: "#fff",
|
|
||||||
}}
|
|
||||||
placeholder={t("search.search")}
|
|
||||||
placeholderTextColor='rgba(255,255,255,0.4)'
|
|
||||||
onChangeText={setSearch}
|
|
||||||
defaultValue=''
|
|
||||||
autoFocus={false}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Graduated build identifier — see utils/version.ts:
|
// Graduated build identifier — see utils/version.ts:
|
||||||
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit · #run", production → "0.54.1".
|
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit", production → "0.54.1 (42)".
|
||||||
const { display: version } = getVersionInfo();
|
const { display: version } = getVersionInfo();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Apple TV uses a Top Shelf extension target, not the main app process.
|
|||||||
|
|
||||||
Relevant files:
|
Relevant files:
|
||||||
|
|
||||||
- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts)
|
- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js)
|
||||||
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
|
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
|
||||||
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
|
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
|
||||||
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)
|
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)
|
||||||
|
|||||||
8
eas.json
8
eas.json
@@ -97,14 +97,6 @@
|
|||||||
"credentialsSource": "local",
|
"credentialsSource": "local",
|
||||||
"config": "ios-production.yml"
|
"config": "ios-production.yml"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"ci": {
|
|
||||||
"extends": "production",
|
|
||||||
"autoIncrement": false
|
|
||||||
},
|
|
||||||
"ci_tv": {
|
|
||||||
"extends": "production_tv",
|
|
||||||
"autoIncrement": false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"submit": {
|
"submit": {
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application>
|
<application>
|
||||||
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
|
<receiver
|
||||||
|
android:name=".TvRecommendationsReceiver"
|
||||||
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
|
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
@@ -16,13 +16,12 @@ import androidx.tvprovider.media.tv.PreviewProgram
|
|||||||
import androidx.tvprovider.media.tv.TvContractCompat
|
import androidx.tvprovider.media.tv.TvContractCompat
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.security.MessageDigest
|
|
||||||
|
|
||||||
internal object TvRecommendationsPublisher {
|
internal object TvRecommendationsPublisher {
|
||||||
private const val TAG = "TvRecommendations"
|
private const val TAG = "TvRecommendations"
|
||||||
private const val PREFS_NAME = "StreamyfinTvRecommendations"
|
private const val PREFS_NAME = "StreamyfinTvRecommendations"
|
||||||
private const val KEY_PAYLOAD = "payload"
|
private const val KEY_PAYLOAD = "payload"
|
||||||
private const val KEY_CHANNEL_ID_PREFIX = "channelId_"
|
private const val KEY_CHANNEL_ID = "channelId"
|
||||||
private const val KEY_PROGRAM_IDS = "programIds"
|
private const val KEY_PROGRAM_IDS = "programIds"
|
||||||
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
|
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
|
||||||
|
|
||||||
@@ -62,61 +61,31 @@ internal object TvRecommendationsPublisher {
|
|||||||
|
|
||||||
fun clear(context: Context): Boolean {
|
fun clear(context: Context): Boolean {
|
||||||
val prefs = preferences(context)
|
val prefs = preferences(context)
|
||||||
|
val channelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
||||||
|
val programIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
// KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" }
|
if (programIds != null) {
|
||||||
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
|
||||||
if (allProgramIds != null) {
|
|
||||||
var deletedPrograms = 0
|
var deletedPrograms = 0
|
||||||
val channelKeys = allProgramIds.keys()
|
val keys = programIds.keys()
|
||||||
while (channelKeys.hasNext()) {
|
while (keys.hasNext()) {
|
||||||
val channelIdStr = channelKeys.next()
|
val key = keys.next()
|
||||||
val programIdsJson = allProgramIds.optString(channelIdStr)
|
val programId = programIds.optLong(key, -1L)
|
||||||
if (programIdsJson.isBlank()) continue
|
if (programId > 0L) {
|
||||||
|
contentResolver.delete(
|
||||||
try {
|
TvContractCompat.buildPreviewProgramUri(programId),
|
||||||
val programIds = JSONObject(programIdsJson)
|
null,
|
||||||
val keys = programIds.keys()
|
null
|
||||||
while (keys.hasNext()) {
|
)
|
||||||
val providerId = keys.next()
|
deletedPrograms += 1
|
||||||
val programId = programIds.optLong(providerId, -1L)
|
|
||||||
if (programId > 0L) {
|
|
||||||
deletePreviewProgram(contentResolver, programId)
|
|
||||||
deletedPrograms += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "clear(): failed to parse programIds for channel $channelIdStr", e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify the channel
|
|
||||||
val channelId = channelIdStr.toLongOrNull() ?: -1L
|
|
||||||
if (channelId > 0L) {
|
|
||||||
try {
|
|
||||||
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "clear(): lost provider permission, cannot notify channel $channelId", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove per-channel pref
|
|
||||||
prefs.edit().remove("programIds_$channelIdStr").apply()
|
|
||||||
}
|
}
|
||||||
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
|
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also handle legacy format (flat { providerId: programId }) for migration
|
if (channelId > 0L) {
|
||||||
val legacyProgramIds = prefs.getString("legacy_" + KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
|
||||||
if (legacyProgramIds != null) {
|
Log.d(TAG, "clear(): notified channel $channelId")
|
||||||
val keys = legacyProgramIds.keys()
|
|
||||||
while (keys.hasNext()) {
|
|
||||||
val key = keys.next()
|
|
||||||
val programId = legacyProgramIds.optLong(key, -1L)
|
|
||||||
if (programId > 0L) {
|
|
||||||
deletePreviewProgram(contentResolver, programId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prefs.edit().remove("legacy_" + KEY_PROGRAM_IDS).apply()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
@@ -127,274 +96,128 @@ internal object TvRecommendationsPublisher {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a single preview program from the TvProvider.
|
|
||||||
* Called by clear(), synchronize() (stale programs), and TvRecommendationsReceiver (user removed).
|
|
||||||
*/
|
|
||||||
fun deletePreviewProgram(context: Context, programId: Long) {
|
|
||||||
try {
|
|
||||||
context.contentResolver.delete(
|
|
||||||
TvContractCompat.buildPreviewProgramUri(programId),
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
Log.d(TAG, "deletePreviewProgram(): deleted programId=$programId")
|
|
||||||
|
|
||||||
// Also remove from stored programIds prefs
|
|
||||||
removeProgramFromPrefs(context, programId)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deletePreviewProgram(contentResolver: android.content.ContentResolver, programId: Long) {
|
|
||||||
try {
|
|
||||||
contentResolver.delete(
|
|
||||||
TvContractCompat.buildPreviewProgramUri(programId),
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun removeProgramFromPrefs(context: Context, programId: Long) {
|
|
||||||
val prefs = preferences(context)
|
|
||||||
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
|
|
||||||
try {
|
|
||||||
val channelMap = JSONObject(programIdsJson)
|
|
||||||
val channelKeys = channelMap.keys()
|
|
||||||
while (channelKeys.hasNext()) {
|
|
||||||
val channelId = channelKeys.next()
|
|
||||||
val inner = channelMap.optJSONObject(channelId) ?: continue
|
|
||||||
val providerKeys = inner.keys()
|
|
||||||
while (providerKeys.hasNext()) {
|
|
||||||
val providerId = providerKeys.next()
|
|
||||||
if (inner.optLong(providerId, -1L) == programId) {
|
|
||||||
inner.remove(providerId)
|
|
||||||
if (inner.length() == 0) {
|
|
||||||
channelMap.remove(channelId)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prefs.edit().putString(KEY_PROGRAM_IDS, channelMap.toString()).apply()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun synchronize(context: Context, payload: JSONObject): Boolean {
|
private fun synchronize(context: Context, payload: JSONObject): Boolean {
|
||||||
val sections = payload.optJSONArray("sections") ?: JSONArray()
|
val sections = payload.optJSONArray("sections") ?: JSONArray()
|
||||||
if (sections.length() == 0) {
|
val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null
|
||||||
Log.w(TAG, "synchronize(): no sections in payload")
|
val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
|
||||||
return false
|
val items = firstSection?.optJSONArray("items") ?: JSONArray()
|
||||||
}
|
|
||||||
|
|
||||||
val prefs = preferences(context)
|
|
||||||
val allNextProgramIds = JSONObject()
|
|
||||||
var totalActive = 0
|
|
||||||
var totalDeleted = 0
|
|
||||||
|
|
||||||
for (sectionIndex in 0 until sections.length()) {
|
|
||||||
val section = sections.optJSONObject(sectionIndex) ?: continue
|
|
||||||
val sectionTitle = section.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
|
|
||||||
val items = section.optJSONArray("items") ?: JSONArray()
|
|
||||||
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
val channelId = getOrCreateChannel(context, sectionTitle)
|
|
||||||
if (channelId <= 0L) {
|
|
||||||
Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per Android docs: check channel.isBrowsable() and request if needed.
|
|
||||||
if (!isChannelBrowsable(context, channelId)) {
|
|
||||||
Log.d(TAG, "synchronize(): channel $channelId not browsable, requesting browsable")
|
|
||||||
TvContractCompat.requestChannelBrowsable(context, channelId)
|
|
||||||
}
|
|
||||||
|
|
||||||
val prefKey = "programIds_$channelId"
|
|
||||||
val previousProgramIds = prefs.getString(prefKey, null)
|
|
||||||
?.let(::JSONObject)
|
|
||||||
?: JSONObject()
|
|
||||||
val nextProgramIds = JSONObject()
|
|
||||||
val activeProviderIds = mutableSetOf<String>()
|
|
||||||
|
|
||||||
for (index in 0 until items.length()) {
|
|
||||||
val item = items.optJSONObject(index) ?: continue
|
|
||||||
val providerId = item.optString("id")
|
|
||||||
if (providerId.isBlank()) continue
|
|
||||||
|
|
||||||
val programId = upsertPreviewProgram(
|
|
||||||
context = context,
|
|
||||||
channelId = channelId,
|
|
||||||
item = item,
|
|
||||||
previousProgramId = previousProgramIds.optLong(providerId, -1L),
|
|
||||||
weight = index
|
|
||||||
)
|
|
||||||
|
|
||||||
if (programId > 0L) {
|
|
||||||
activeProviderIds += providerId
|
|
||||||
nextProgramIds.put(providerId, programId)
|
|
||||||
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var deletedPrograms = 0
|
|
||||||
val previousKeys = previousProgramIds.keys()
|
|
||||||
while (previousKeys.hasNext()) {
|
|
||||||
val providerId = previousKeys.next()
|
|
||||||
if (activeProviderIds.contains(providerId)) continue
|
|
||||||
|
|
||||||
val programId = previousProgramIds.optLong(providerId, -1L)
|
|
||||||
if (programId > 0L) {
|
|
||||||
deletePreviewProgram(context, programId)
|
|
||||||
deletedPrograms += 1
|
|
||||||
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
allNextProgramIds.put(channelId.toString(), nextProgramIds.toString())
|
|
||||||
prefs.edit().putString(prefKey, nextProgramIds.toString()).apply()
|
|
||||||
totalActive += activeProviderIds.size
|
|
||||||
totalDeleted += deletedPrograms
|
|
||||||
|
|
||||||
logProviderState(context, channelId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store all channel program IDs for clear() to use
|
|
||||||
prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
|
|
||||||
|
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
"synchronize(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted stale program(s)"
|
"synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
val channelId = getOrCreateChannel(context, sectionTitle)
|
||||||
|
if (channelId <= 0L) {
|
||||||
|
Log.w(TAG, "synchronize(): failed to get or create preview channel")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "synchronize(): publishing into channelId=$channelId")
|
||||||
|
|
||||||
|
val previousProgramIds = preferences(context)
|
||||||
|
.getString(KEY_PROGRAM_IDS, null)
|
||||||
|
?.let(::JSONObject)
|
||||||
|
?: JSONObject()
|
||||||
|
val nextProgramIds = JSONObject()
|
||||||
|
val activeProviderIds = mutableSetOf<String>()
|
||||||
|
|
||||||
|
for (index in 0 until items.length()) {
|
||||||
|
val item = items.optJSONObject(index) ?: continue
|
||||||
|
val providerId = item.optString("id")
|
||||||
|
if (providerId.isBlank()) continue
|
||||||
|
|
||||||
|
val programId = upsertPreviewProgram(
|
||||||
|
context = context,
|
||||||
|
channelId = channelId,
|
||||||
|
item = item,
|
||||||
|
previousProgramId = previousProgramIds.optLong(providerId, -1L),
|
||||||
|
weight = index
|
||||||
|
)
|
||||||
|
|
||||||
|
if (programId > 0L) {
|
||||||
|
activeProviderIds += providerId
|
||||||
|
nextProgramIds.put(providerId, programId)
|
||||||
|
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedPrograms = 0
|
||||||
|
val previousKeys = previousProgramIds.keys()
|
||||||
|
while (previousKeys.hasNext()) {
|
||||||
|
val providerId = previousKeys.next()
|
||||||
|
if (activeProviderIds.contains(providerId)) continue
|
||||||
|
|
||||||
|
val programId = previousProgramIds.optLong(providerId, -1L)
|
||||||
|
if (programId > 0L) {
|
||||||
|
context.contentResolver.delete(
|
||||||
|
TvContractCompat.buildPreviewProgramUri(programId),
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
deletedPrograms += 1
|
||||||
|
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
preferences(context)
|
||||||
|
.edit()
|
||||||
|
.putLong(KEY_CHANNEL_ID, channelId)
|
||||||
|
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString())
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
logProviderState(context, channelId)
|
||||||
|
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)"
|
||||||
)
|
)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Query provider to check if a channel is browsable.
|
|
||||||
* Per Android docs: "check channel.isBrowsable() before updating programs."
|
|
||||||
*/
|
|
||||||
private fun isChannelBrowsable(context: Context, channelId: Long): Boolean {
|
|
||||||
return try {
|
|
||||||
context.contentResolver.query(
|
|
||||||
TvContractCompat.buildChannelUri(channelId),
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
)?.use { cursor ->
|
|
||||||
if (cursor.moveToFirst()) {
|
|
||||||
val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE)
|
|
||||||
if (browsableIndex >= 0) cursor.getInt(browsableIndex) == 1 else true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} ?: false
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "isChannelBrowsable(): lost provider permission for channelId=$channelId", e)
|
|
||||||
true // Assume browsable if we can't check, to avoid blocking updates
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query provider to verify a channel actually exists.
|
|
||||||
* Prevents the channel-delete-recreate bug: if update() returns 0 rows
|
|
||||||
* we must first check whether the channel was deleted by the system
|
|
||||||
* or if the update simply failed for another reason.
|
|
||||||
*/
|
|
||||||
private fun channelExistsInProvider(context: Context, channelId: Long): Boolean {
|
|
||||||
return try {
|
|
||||||
context.contentResolver.query(
|
|
||||||
TvContractCompat.buildChannelUri(channelId),
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
)?.use { cursor ->
|
|
||||||
cursor.moveToFirst()
|
|
||||||
} ?: false
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "channelExistsInProvider(): lost provider permission for channelId=$channelId", e)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getOrCreateChannel(context: Context, displayName: String): Long {
|
private fun getOrCreateChannel(context: Context, displayName: String): Long {
|
||||||
val prefs = preferences(context)
|
val prefs = preferences(context)
|
||||||
val channelKey = getChannelKey(displayName)
|
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
||||||
val existingChannelId = prefs.getLong(channelKey, -1L)
|
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
if (existingChannelId > 0L) {
|
if (existingChannelId > 0L) {
|
||||||
// Query provider first to verify channel actually exists (prevents recreate bug)
|
val updated = Channel.Builder()
|
||||||
val exists = channelExistsInProvider(context, existingChannelId)
|
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||||
|
.setDisplayName(displayName)
|
||||||
|
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||||
|
.build()
|
||||||
|
|
||||||
if (exists) {
|
val updatedRows = contentResolver.update(
|
||||||
// Channel exists — update it in place, never recreate
|
TvContractCompat.buildChannelUri(existingChannelId),
|
||||||
val updated = Channel.Builder()
|
updated.toContentValues(),
|
||||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
null,
|
||||||
.setDisplayName(displayName)
|
null
|
||||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
)
|
||||||
.build()
|
|
||||||
|
|
||||||
try {
|
if (updatedRows > 0) {
|
||||||
val updatedRows = contentResolver.update(
|
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
|
||||||
TvContractCompat.buildChannelUri(existingChannelId),
|
storeChannelLogo(context, existingChannelId)
|
||||||
updated.toContentValues(),
|
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
|
||||||
null,
|
return existingChannelId
|
||||||
null
|
|
||||||
)
|
|
||||||
|
|
||||||
if (updatedRows > 0) {
|
|
||||||
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
|
|
||||||
storeChannelLogo(context, existingChannelId)
|
|
||||||
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
|
|
||||||
return existingChannelId
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update returned 0 rows but channel exists — log and return existing ID, don't recreate
|
|
||||||
Log.e(TAG, "getOrCreateChannel(): update returned 0 for existing channelId=$existingChannelId but channel exists — not recreating")
|
|
||||||
return existingChannelId
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "getOrCreateChannel(): lost provider permission updating channelId=$existingChannelId", e)
|
|
||||||
return existingChannelId
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channel truly doesn't exist in provider — recreate
|
Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating")
|
||||||
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
|
prefs.edit().remove(KEY_CHANNEL_ID).apply()
|
||||||
prefs.edit().remove(channelKey).apply()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new channel
|
|
||||||
val channel = Channel.Builder()
|
val channel = Channel.Builder()
|
||||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||||
.setDisplayName(displayName)
|
.setDisplayName(displayName)
|
||||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val channelUri = try {
|
val channelUri = contentResolver.insert(
|
||||||
contentResolver.insert(
|
TvContractCompat.Channels.CONTENT_URI,
|
||||||
TvContractCompat.Channels.CONTENT_URI,
|
channel.toContentValues()
|
||||||
channel.toContentValues()
|
) ?: return -1L
|
||||||
)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.e(TAG, "getOrCreateChannel(): lost provider permission, cannot create channel", e)
|
|
||||||
null
|
|
||||||
} ?: return -1L
|
|
||||||
|
|
||||||
val channelId = ContentUris.parseId(channelUri)
|
val channelId = ContentUris.parseId(channelUri)
|
||||||
prefs.edit().putLong(channelKey, channelId).apply()
|
|
||||||
TvContractCompat.requestChannelBrowsable(context, channelId)
|
TvContractCompat.requestChannelBrowsable(context, channelId)
|
||||||
storeChannelLogo(context, channelId)
|
storeChannelLogo(context, channelId)
|
||||||
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
|
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
|
||||||
@@ -402,10 +225,6 @@ internal object TvRecommendationsPublisher {
|
|||||||
return channelId
|
return channelId
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getChannelKey(displayName: String): String {
|
|
||||||
return KEY_CHANNEL_ID_PREFIX + displayName.hashCode()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun upsertPreviewProgram(
|
private fun upsertPreviewProgram(
|
||||||
context: Context,
|
context: Context,
|
||||||
channelId: Long,
|
channelId: Long,
|
||||||
@@ -430,67 +249,42 @@ internal object TvRecommendationsPublisher {
|
|||||||
builder.setDescription(it)
|
builder.setDescription(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per Android docs: use unique URIs for all images to avoid stale cache
|
|
||||||
imageUrl.takeIf { it.isNotBlank() }?.let {
|
imageUrl.takeIf { it.isNotBlank() }?.let {
|
||||||
val uniqueImageUrl = appendCacheBuster(it)
|
val imageUri = Uri.parse(it)
|
||||||
val imageUri = Uri.parse(uniqueImageUrl)
|
|
||||||
builder.setPosterArtUri(imageUri)
|
builder.setPosterArtUri(imageUri)
|
||||||
builder.setThumbnailUri(imageUri)
|
builder.setThumbnailUri(imageUri)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val contentValues = builder.build().toContentValues()
|
val contentValues = builder.build().toContentValues()
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
if (previousProgramId > 0L) {
|
if (previousProgramId > 0L) {
|
||||||
try {
|
val updatedRows = contentResolver.update(
|
||||||
val updatedRows = contentResolver.update(
|
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
||||||
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
contentValues,
|
||||||
contentValues,
|
null,
|
||||||
null,
|
null
|
||||||
null
|
)
|
||||||
)
|
|
||||||
|
|
||||||
if (updatedRows > 0) {
|
if (updatedRows > 0) {
|
||||||
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
|
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
|
||||||
return previousProgramId
|
return previousProgramId
|
||||||
}
|
|
||||||
|
|
||||||
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "upsertPreviewProgram(): lost provider permission for programId=$previousProgramId", e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
|
||||||
}
|
}
|
||||||
|
|
||||||
val insertedUri = try {
|
val insertedUri = contentResolver.insert(
|
||||||
contentResolver.insert(
|
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||||
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
contentValues
|
||||||
contentValues
|
) ?: return -1L
|
||||||
)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "upsertPreviewProgram(): lost provider permission, cannot insert program", e)
|
|
||||||
null
|
|
||||||
} ?: return -1L
|
|
||||||
|
|
||||||
val programId = ContentUris.parseId(insertedUri)
|
val programId = ContentUris.parseId(insertedUri)
|
||||||
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
|
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
|
||||||
return programId
|
return programId
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Append a stable cache key derived from the image URL.
|
|
||||||
* The Jellyfin image URLs already include a `tag=` query param (etag)
|
|
||||||
* that changes whenever the image content changes, so a deterministic
|
|
||||||
* hash of the URL is sufficient — the param only changes when the URL
|
|
||||||
* (and therefore the image) actually changes, avoiding unnecessary
|
|
||||||
* re-downloads on every sync.
|
|
||||||
*/
|
|
||||||
private fun appendCacheBuster(imageUrl: String): String {
|
|
||||||
val digest = MessageDigest.getInstance("MD5").digest(imageUrl.toByteArray())
|
|
||||||
val hash = digest.joinToString("") { "%02x".format(it) }.substring(0, 16)
|
|
||||||
val separator = if (imageUrl.contains("?")) "&" else "?"
|
|
||||||
return "$imageUrl${separator}_v=$hash"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildIntentUri(context: Context, deepLink: String): Uri {
|
private fun buildIntentUri(context: Context, deepLink: String): Uri {
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
data = Uri.parse(deepLink)
|
data = Uri.parse(deepLink)
|
||||||
@@ -512,17 +306,13 @@ internal object TvRecommendationsPublisher {
|
|||||||
|
|
||||||
private fun storeChannelLogo(context: Context, channelId: Long) {
|
private fun storeChannelLogo(context: Context, channelId: Long) {
|
||||||
val bitmap = applicationIconBitmap(context) ?: return
|
val bitmap = applicationIconBitmap(context) ?: return
|
||||||
try {
|
val outputStream = context.contentResolver.openOutputStream(
|
||||||
val outputStream = context.contentResolver.openOutputStream(
|
TvContractCompat.buildChannelLogoUri(channelId)
|
||||||
TvContractCompat.buildChannelLogoUri(channelId)
|
) ?: return
|
||||||
) ?: return
|
|
||||||
|
|
||||||
outputStream.use { stream ->
|
outputStream.use { stream ->
|
||||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||||
stream.flush()
|
stream.flush()
|
||||||
}
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "storeChannelLogo(): lost provider permission for channelId=$channelId", e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,14 +341,9 @@ internal object TvRecommendationsPublisher {
|
|||||||
return bitmap
|
return bitmap
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChannelId(context: Context, displayName: String = DEFAULT_CHANNEL_NAME): Long {
|
|
||||||
return preferences(context).getLong(getChannelKey(displayName), -1L)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun preferences(context: Context): SharedPreferences {
|
private fun preferences(context: Context): SharedPreferences {
|
||||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun logProviderState(context: Context, channelId: Long) {
|
private fun logProviderState(context: Context, channelId: Long) {
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
@@ -587,10 +372,8 @@ internal object TvRecommendationsPublisher {
|
|||||||
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
|
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
|
||||||
}
|
}
|
||||||
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
|
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
|
||||||
} catch (error: SecurityException) {
|
} catch (error: Exception) {
|
||||||
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
|
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
|
||||||
} catch (error: Exception) {
|
}
|
||||||
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,16 @@ package expo.modules.tvrecommendations
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ContentUris
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.tvprovider.media.tv.TvContractCompat
|
import androidx.tvprovider.media.tv.TvContractCompat
|
||||||
|
|
||||||
class TvRecommendationsReceiver : BroadcastReceiver() {
|
class TvRecommendationsReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
when (intent.action) {
|
if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) {
|
||||||
TvContractCompat.ACTION_INITIALIZE_PROGRAMS -> {
|
return
|
||||||
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
|
|
||||||
TvRecommendationsPublisher.refreshFromCache(context)
|
|
||||||
}
|
|
||||||
"android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" -> {
|
|
||||||
val programId = intent.data?.let { ContentUris.parseId(it) } ?: -1L
|
|
||||||
if (programId > 0L) {
|
|
||||||
Log.d("TvRecommendations", "Handling PREVIEW_PROGRAM_BROWSABLE_DISABLED for programId=$programId")
|
|
||||||
TvRecommendationsPublisher.deletePreviewProgram(context, programId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
|
||||||
|
TvRecommendationsPublisher.refreshFromCache(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
import { requireNativeView } from "expo";
|
import { requireNativeView } from "expo";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import type { View } from "react-native";
|
import type { View } from "react-native";
|
||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
import type { TvSearchViewProps } from "./TvSearchView.types";
|
import type { TvSearchViewProps } from "./TvSearchView.types";
|
||||||
|
|
||||||
// The native TvSearchModule is Apple-only (tvOS SwiftUI `.searchable`).
|
|
||||||
// On Android the component is never rendered, but we must avoid calling
|
|
||||||
// `requireNativeView` at module-scope because it would crash on import.
|
|
||||||
const NativeView: React.ComponentType<
|
const NativeView: React.ComponentType<
|
||||||
TvSearchViewProps & React.RefAttributes<View>
|
TvSearchViewProps & React.RefAttributes<View>
|
||||||
> =
|
> = requireNativeView("TvSearchModule");
|
||||||
Platform.OS === "ios"
|
|
||||||
? requireNativeView("TvSearchModule")
|
|
||||||
: ((() => null) as any);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forwards its ref to the underlying native view so it can be used as a
|
* Forwards its ref to the underlying native view so it can be used as a
|
||||||
|
|||||||
@@ -135,7 +135,6 @@
|
|||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "17.0.5",
|
||||||
"react-test-renderer": "19.2.3",
|
"react-test-renderer": "19.2.3",
|
||||||
"tsx": "^4.22.4",
|
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3"
|
||||||
},
|
},
|
||||||
"expo": {
|
"expo": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
|
const { withPodfile } = require("expo/config-plugins");
|
||||||
|
|
||||||
const PATCH_START = "## >>> runtime-framework headers";
|
const PATCH_START = "## >>> runtime-framework headers";
|
||||||
const PATCH_END = "## <<< runtime-framework headers";
|
const PATCH_END = "## <<< runtime-framework headers";
|
||||||
@@ -13,7 +13,7 @@ const EXTRA_HDRS = [
|
|||||||
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
|
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
|
||||||
];
|
];
|
||||||
|
|
||||||
function buildPatch(): string {
|
function buildPatch() {
|
||||||
return [
|
return [
|
||||||
PATCH_START,
|
PATCH_START,
|
||||||
" extra_hdrs = [",
|
" extra_hdrs = [",
|
||||||
@@ -91,7 +91,7 @@ function buildPatch(): string {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
const withRuntimeFrameworkHeaders: ConfigPlugin = (config) => {
|
module.exports = function withRuntimeFrameworkHeaders(config) {
|
||||||
return withPodfile(config, (config) => {
|
return withPodfile(config, (config) => {
|
||||||
let podfile = config.modResults.contents;
|
let podfile = config.modResults.contents;
|
||||||
|
|
||||||
@@ -125,5 +125,3 @@ end
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withRuntimeFrameworkHeaders;
|
|
||||||
@@ -1,20 +1,10 @@
|
|||||||
import {
|
const {
|
||||||
type ConfigPlugin,
|
|
||||||
withAndroidColors,
|
withAndroidColors,
|
||||||
withAndroidColorsNight,
|
withAndroidColorsNight,
|
||||||
} from "expo/config-plugins";
|
} = require("expo/config-plugins");
|
||||||
|
|
||||||
interface ColorResourceItem {
|
const withAndroidAlertColors = (config) => {
|
||||||
$: { name: string };
|
const setColor = (colorsList, name, value) => {
|
||||||
_: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const withAndroidAlertColors: ConfigPlugin = (config) => {
|
|
||||||
const setColor = (
|
|
||||||
colorsList: ColorResourceItem[],
|
|
||||||
name: string,
|
|
||||||
value: string,
|
|
||||||
) => {
|
|
||||||
const existingColor = colorsList.find(
|
const existingColor = colorsList.find(
|
||||||
(item) => item.$ && item.$.name === name,
|
(item) => item.$ && item.$.name === name,
|
||||||
);
|
);
|
||||||
@@ -30,7 +20,7 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
|
|||||||
|
|
||||||
config = withAndroidColors(config, (config) => {
|
config = withAndroidColors(config, (config) => {
|
||||||
const colors = config.modResults;
|
const colors = config.modResults;
|
||||||
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
|
const colorsList = colors.resources.color || [];
|
||||||
setColor(colorsList, "colorPrimary", "#000000");
|
setColor(colorsList, "colorPrimary", "#000000");
|
||||||
colors.resources.color = colorsList;
|
colors.resources.color = colorsList;
|
||||||
return config;
|
return config;
|
||||||
@@ -38,7 +28,7 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
|
|||||||
|
|
||||||
config = withAndroidColorsNight(config, (config) => {
|
config = withAndroidColorsNight(config, (config) => {
|
||||||
const colors = config.modResults;
|
const colors = config.modResults;
|
||||||
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
|
const colorsList = colors.resources.color || [];
|
||||||
setColor(colorsList, "colorPrimary", "#FFFFFF");
|
setColor(colorsList, "colorPrimary", "#FFFFFF");
|
||||||
colors.resources.color = colorsList;
|
colors.resources.color = colorsList;
|
||||||
return config;
|
return config;
|
||||||
@@ -47,4 +37,4 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
|
|||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withAndroidAlertColors;
|
module.exports = withAndroidAlertColors;
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
import { type ConfigPlugin, withAndroidManifest } from "expo/config-plugins";
|
const { withAndroidManifest } = require("expo/config-plugins");
|
||||||
|
|
||||||
const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
|
const _withGoogleCastAndroidManifest = (config) =>
|
||||||
withAndroidManifest(config, async (mod) => {
|
withAndroidManifest(config, async (mod) => {
|
||||||
const mainApplication = mod.modResults.manifest.application?.[0];
|
const mainApplication = mod.modResults.manifest.application[0];
|
||||||
|
|
||||||
if (!mainApplication) {
|
|
||||||
return mod;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize activity array if it doesn't exist
|
// Initialize activity array if it doesn't exist
|
||||||
if (!mainApplication.activity) {
|
if (!mainApplication.activity) {
|
||||||
@@ -43,4 +39,4 @@ const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
|
|||||||
return mod;
|
return mod;
|
||||||
});
|
});
|
||||||
|
|
||||||
export default withGoogleCastAndroidManifest;
|
module.exports = _withGoogleCastAndroidManifest;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { readFileSync, writeFileSync } from "node:fs";
|
const { readFileSync, writeFileSync } = require("node:fs");
|
||||||
import { join } from "node:path";
|
const { join } = require("node:path");
|
||||||
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
|
const { withDangerousMod } = require("expo/config-plugins");
|
||||||
|
|
||||||
const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
|
const withChangeNativeAndroidTextToWhite = (expoConfig) =>
|
||||||
withDangerousMod(expoConfig, [
|
withDangerousMod(expoConfig, [
|
||||||
"android",
|
"android",
|
||||||
(modConfig) => {
|
(modConfig) => {
|
||||||
@@ -30,4 +30,4 @@ const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default withChangeNativeAndroidTextToWhite;
|
module.exports = withChangeNativeAndroidTextToWhite;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type ConfigPlugin, withAppBuildGradle } from "expo/config-plugins";
|
const { withAppBuildGradle } = require("expo/config-plugins");
|
||||||
|
|
||||||
const withExcludeMedia3Dash: ConfigPlugin = (config) => {
|
module.exports = function withExcludeMedia3Dash(config) {
|
||||||
return withAppBuildGradle(config, (config) => {
|
return withAppBuildGradle(config, (config) => {
|
||||||
const contents = config.modResults.contents;
|
const contents = config.modResults.contents;
|
||||||
|
|
||||||
@@ -32,5 +32,3 @@ configurations.all {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withExcludeMedia3Dash;
|
|
||||||
@@ -1,14 +1,6 @@
|
|||||||
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
|
const { withPodfile } = require("@expo/config-plugins");
|
||||||
|
|
||||||
interface GitPodOptions {
|
const withGitPod = (config, { podName, podspecUrl }) => {
|
||||||
podName: string;
|
|
||||||
podspecUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const withGitPod: ConfigPlugin<GitPodOptions> = (
|
|
||||||
config,
|
|
||||||
{ podName, podspecUrl },
|
|
||||||
) => {
|
|
||||||
return withPodfile(config, (config) => {
|
return withPodfile(config, (config) => {
|
||||||
const podfile = config.modResults.contents;
|
const podfile = config.modResults.contents;
|
||||||
|
|
||||||
@@ -29,4 +21,4 @@ const withGitPod: ConfigPlugin<GitPodOptions> = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withGitPod;
|
module.exports = withGitPod;
|
||||||
@@ -1,21 +1,12 @@
|
|||||||
import type { ExpoConfig } from "expo/config";
|
const { withGradleProperties } = require("expo/config-plugins");
|
||||||
import {
|
|
||||||
AndroidConfig,
|
|
||||||
type ConfigPlugin,
|
|
||||||
withGradleProperties,
|
|
||||||
} from "expo/config-plugins";
|
|
||||||
|
|
||||||
function setGradlePropertiesValue(
|
function setGradlePropertiesValue(config, key, value) {
|
||||||
config: ExpoConfig,
|
|
||||||
key: string,
|
|
||||||
value: string,
|
|
||||||
): ExpoConfig {
|
|
||||||
return withGradleProperties(config, (exportedConfig) => {
|
return withGradleProperties(config, (exportedConfig) => {
|
||||||
const props = exportedConfig.modResults;
|
const props = exportedConfig.modResults;
|
||||||
const keyIdx = props.findIndex(
|
const keyIdx = props.findIndex(
|
||||||
(item) => item.type === "property" && item.key === key,
|
(item) => item.type === "property" && item.key === key,
|
||||||
);
|
);
|
||||||
const property: AndroidConfig.Properties.PropertiesItem = {
|
const property = {
|
||||||
type: "property",
|
type: "property",
|
||||||
key,
|
key,
|
||||||
value,
|
value,
|
||||||
@@ -31,7 +22,7 @@ function setGradlePropertiesValue(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const withCustomGradleProperties: ConfigPlugin = (config) => {
|
module.exports = function withCustomPlugin(config) {
|
||||||
// Expo 52 is not setting this
|
// Expo 52 is not setting this
|
||||||
// https://github.com/expo/expo/issues/32558
|
// https://github.com/expo/expo/issues/32558
|
||||||
config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
|
config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
|
||||||
@@ -44,5 +35,3 @@ const withCustomGradleProperties: ConfigPlugin = (config) => {
|
|||||||
);
|
);
|
||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withCustomGradleProperties;
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type ConfigPlugin, withXcodeProject } from "expo/config-plugins";
|
const { withXcodeProject } = require("@expo/config-plugins");
|
||||||
|
|
||||||
const withTVOSAppIcon: ConfigPlugin = (config) => {
|
const withTVOSAppIcon = (config) => {
|
||||||
// Only apply for TV builds
|
// Only apply for TV builds
|
||||||
if (process.env.EXPO_TV !== "1") {
|
if (process.env.EXPO_TV !== "1") {
|
||||||
return config;
|
return config;
|
||||||
@@ -28,4 +28,4 @@ const withTVOSAppIcon: ConfigPlugin = (config) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withTVOSAppIcon;
|
module.exports = withTVOSAppIcon;
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import type { ExpoConfig } from "expo/config";
|
const {
|
||||||
import {
|
|
||||||
type ConfigPlugin,
|
|
||||||
withEntitlementsPlist,
|
withEntitlementsPlist,
|
||||||
withInfoPlist,
|
withInfoPlist,
|
||||||
withXcodeProject,
|
withXcodeProject,
|
||||||
} from "expo/config-plugins";
|
} = require("@expo/config-plugins");
|
||||||
|
|
||||||
const EXTENSION_TARGET_NAME = "StreamyfinTopShelf";
|
const EXTENSION_TARGET_NAME = "StreamyfinTopShelf";
|
||||||
const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf";
|
const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf";
|
||||||
@@ -12,29 +10,19 @@ const APP_GROUP_INFO_PLIST_KEY = "StreamyfinAppGroupIdentifier";
|
|||||||
const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY =
|
const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY =
|
||||||
"StreamyfinKeychainAccessGroupIdentifier";
|
"StreamyfinKeychainAccessGroupIdentifier";
|
||||||
|
|
||||||
interface AppExtensionConfig {
|
function getBundleIdentifier(config) {
|
||||||
targetName: string;
|
|
||||||
bundleIdentifier: string;
|
|
||||||
entitlements: {
|
|
||||||
"com.apple.security.application-groups": string[];
|
|
||||||
"keychain-access-groups": string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBundleIdentifier(config: ExpoConfig): string {
|
|
||||||
return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin";
|
return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAppGroupIdentifier(config: ExpoConfig): string {
|
function getAppGroupIdentifier(config) {
|
||||||
return `group.${getBundleIdentifier(config)}.tvtopshelf`;
|
return `group.${getBundleIdentifier(config)}.tvtopshelf`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getKeychainAccessGroupIdentifier(config: ExpoConfig): string {
|
function getKeychainAccessGroupIdentifier(config) {
|
||||||
return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`;
|
return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The xcode project object has no usable typings — keep `any` here.
|
function getBuildConfigurations(project, configurationListId) {
|
||||||
function getBuildConfigurations(project: any, configurationListId: string) {
|
|
||||||
const configurationList =
|
const configurationList =
|
||||||
project.hash.project.objects.XCConfigurationList[configurationListId];
|
project.hash.project.objects.XCConfigurationList[configurationListId];
|
||||||
|
|
||||||
@@ -42,21 +30,18 @@ function getBuildConfigurations(project: any, configurationListId: string) {
|
|||||||
|
|
||||||
const buildConfigurations = project.pbxXCBuildConfigurationSection();
|
const buildConfigurations = project.pbxXCBuildConfigurationSection();
|
||||||
return configurationList.buildConfigurations
|
return configurationList.buildConfigurations
|
||||||
.map((config: { value: string }) => buildConfigurations[config.value])
|
.map((config) => buildConfigurations[config.value])
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureAppGroup(value: unknown, appGroupIdentifier: string): string[] {
|
function ensureAppGroup(value, appGroupIdentifier) {
|
||||||
const groups = Array.isArray(value) ? value : [];
|
const groups = Array.isArray(value) ? value : [];
|
||||||
return groups.includes(appGroupIdentifier)
|
return groups.includes(appGroupIdentifier)
|
||||||
? groups
|
? groups
|
||||||
: [...groups, appGroupIdentifier];
|
: [...groups, appGroupIdentifier];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureKeychainAccessGroup(
|
function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) {
|
||||||
value: unknown,
|
|
||||||
keychainAccessGroupIdentifier: string,
|
|
||||||
): string[] {
|
|
||||||
const groups = Array.isArray(value) ? value : [];
|
const groups = Array.isArray(value) ? value : [];
|
||||||
return groups.includes(keychainAccessGroupIdentifier)
|
return groups.includes(keychainAccessGroupIdentifier)
|
||||||
? groups
|
? groups
|
||||||
@@ -64,13 +49,13 @@ function ensureKeychainAccessGroup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureAppExtension(
|
function ensureAppExtension(
|
||||||
appExtensions: unknown,
|
appExtensions,
|
||||||
targetName: string,
|
targetName,
|
||||||
bundleIdentifier: string,
|
bundleIdentifier,
|
||||||
appGroupIdentifier: string,
|
appGroupIdentifier,
|
||||||
keychainAccessGroupIdentifier: string,
|
keychainAccessGroupIdentifier,
|
||||||
): AppExtensionConfig[] {
|
) {
|
||||||
const extensionConfig: AppExtensionConfig = {
|
const extensionConfig = {
|
||||||
targetName,
|
targetName,
|
||||||
bundleIdentifier,
|
bundleIdentifier,
|
||||||
entitlements: {
|
entitlements: {
|
||||||
@@ -78,9 +63,7 @@ function ensureAppExtension(
|
|||||||
"keychain-access-groups": [keychainAccessGroupIdentifier],
|
"keychain-access-groups": [keychainAccessGroupIdentifier],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const extensions: AppExtensionConfig[] = Array.isArray(appExtensions)
|
const extensions = Array.isArray(appExtensions) ? appExtensions : [];
|
||||||
? appExtensions
|
|
||||||
: [];
|
|
||||||
// Keep plugin runs idempotent and preserve unrelated app extension entries.
|
// Keep plugin runs idempotent and preserve unrelated app extension entries.
|
||||||
const existingIndex = extensions.findIndex(
|
const existingIndex = extensions.findIndex(
|
||||||
(appExtension) => appExtension?.targetName === targetName,
|
(appExtension) => appExtension?.targetName === targetName,
|
||||||
@@ -95,7 +78,7 @@ function ensureAppExtension(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const withTVOSTopShelf: ConfigPlugin = (config) => {
|
const withTVOSTopShelf = (config) => {
|
||||||
const appGroupIdentifier = getAppGroupIdentifier(config);
|
const appGroupIdentifier = getAppGroupIdentifier(config);
|
||||||
const keychainAccessGroupIdentifier =
|
const keychainAccessGroupIdentifier =
|
||||||
getKeychainAccessGroupIdentifier(config);
|
getKeychainAccessGroupIdentifier(config);
|
||||||
@@ -210,4 +193,4 @@ const withTVOSTopShelf: ConfigPlugin = (config) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withTVOSTopShelf;
|
module.exports = withTVOSTopShelf;
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { type ConfigPlugin, withEntitlementsPlist } from "expo/config-plugins";
|
const { withEntitlementsPlist } = require("expo/config-plugins");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expo config plugin to add User Management entitlement for tvOS profile linking
|
* Expo config plugin to add User Management entitlement for tvOS profile linking
|
||||||
*/
|
*/
|
||||||
const withTVUserManagement: ConfigPlugin = (config) => {
|
const withTVUserManagement = (config) => {
|
||||||
// Only add for tvOS builds. The entitlement is restricted by Apple and must
|
// Only add for tvOS builds. The entitlement is restricted by Apple and must
|
||||||
// be present in the provisioning profile, so injecting it into mobile builds
|
// be present in the provisioning profile, so injecting it into mobile builds
|
||||||
// breaks signing ("Entitlement ... not found and could not be included in
|
// breaks signing ("Entitlement ... not found and could not be included in
|
||||||
@@ -24,4 +24,4 @@ const withTVUserManagement: ConfigPlugin = (config) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withTVUserManagement;
|
module.exports = withTVUserManagement;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { execSync } from "node:child_process";
|
const { withDangerousMod } = require("@expo/config-plugins");
|
||||||
import fs from "node:fs";
|
const { execSync } = require("node:child_process");
|
||||||
import path from "node:path";
|
const fs = require("node:fs");
|
||||||
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
|
const path = require("node:path");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds.
|
* Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds.
|
||||||
@@ -12,7 +12,7 @@ import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
|
|||||||
*
|
*
|
||||||
* It also sets NODE_BINARY for nvm users since Xcode can't resolve shell functions.
|
* It also sets NODE_BINARY for nvm users since Xcode can't resolve shell functions.
|
||||||
*/
|
*/
|
||||||
const withTVXcodeEnv: ConfigPlugin = (config) => {
|
const withTVXcodeEnv = (config) => {
|
||||||
// Only apply for TV builds
|
// Only apply for TV builds
|
||||||
if (process.env.EXPO_TV !== "1") {
|
if (process.env.EXPO_TV !== "1") {
|
||||||
return config;
|
return config;
|
||||||
@@ -70,7 +70,7 @@ const withTVXcodeEnv: ConfigPlugin = (config) => {
|
|||||||
/**
|
/**
|
||||||
* Get the actual node binary path, handling nvm installations.
|
* Get the actual node binary path, handling nvm installations.
|
||||||
*/
|
*/
|
||||||
function getNodeBinaryPath(): string | null {
|
function getNodeBinaryPath() {
|
||||||
try {
|
try {
|
||||||
// First try to get node path directly (works for non-nvm installs)
|
// First try to get node path directly (works for non-nvm installs)
|
||||||
const directPath = execSync("which node 2>/dev/null", {
|
const directPath = execSync("which node 2>/dev/null", {
|
||||||
@@ -114,4 +114,4 @@ function getNodeBinaryPath(): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withTVXcodeEnv;
|
module.exports = withTVXcodeEnv;
|
||||||
@@ -1,29 +1,18 @@
|
|||||||
import fs from "node:fs";
|
const { AndroidConfig, withAndroidManifest } = require("expo/config-plugins");
|
||||||
import path from "node:path";
|
const path = require("node:path");
|
||||||
import {
|
const fs = require("node:fs");
|
||||||
AndroidConfig,
|
|
||||||
type ConfigPlugin,
|
|
||||||
type ExportedConfigWithProps,
|
|
||||||
withAndroidManifest,
|
|
||||||
} from "expo/config-plugins";
|
|
||||||
|
|
||||||
const fsPromises = fs.promises;
|
const fsPromises = fs.promises;
|
||||||
|
|
||||||
const { getMainApplicationOrThrow } = AndroidConfig.Manifest;
|
const { getMainApplicationOrThrow } = AndroidConfig.Manifest;
|
||||||
|
|
||||||
type AndroidManifest = AndroidConfig.Manifest.AndroidManifest;
|
const withTrustLocalCerts = (config) => {
|
||||||
|
|
||||||
const withTrustLocalCerts: ConfigPlugin = (config) => {
|
|
||||||
return withAndroidManifest(config, async (mod) => {
|
return withAndroidManifest(config, async (mod) => {
|
||||||
mod.modResults = await setCustomConfigAsync(mod, mod.modResults);
|
mod.modResults = await setCustomConfigAsync(mod, mod.modResults);
|
||||||
return mod;
|
return mod;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
async function setCustomConfigAsync(
|
async function setCustomConfigAsync(config, androidManifest) {
|
||||||
config: ExportedConfigWithProps<AndroidManifest>,
|
|
||||||
androidManifest: AndroidManifest,
|
|
||||||
): Promise<AndroidManifest> {
|
|
||||||
const src_file_path = path.join(__dirname, "network_security_config.xml");
|
const src_file_path = path.join(__dirname, "network_security_config.xml");
|
||||||
const res_file_path = path.join(
|
const res_file_path = path.join(
|
||||||
await AndroidConfig.Paths.getResourceFolderAsync(
|
await AndroidConfig.Paths.getResourceFolderAsync(
|
||||||
@@ -56,4 +45,4 @@ async function setCustomConfigAsync(
|
|||||||
return androidManifest;
|
return androidManifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withTrustLocalCerts;
|
module.exports = withTrustLocalCerts;
|
||||||
122
scripts/update-issue-form.mjs
Normal file
122
scripts/update-issue-form.mjs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Populates the "Streamyfin Version" dropdown in the issue report form with the
|
||||||
|
* latest GitHub releases. Run by the "Update Issue Form Versions" workflow on
|
||||||
|
* release events + a weekly cron (and manually via workflow_dispatch).
|
||||||
|
*
|
||||||
|
* Source: published, non-draft, non-prerelease GitHub releases, newest first.
|
||||||
|
* Non-version sentinels (e.g. "older", "TestFlight/Development build") are
|
||||||
|
* preserved at the end of the list.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun scripts/update-issue-form.mjs # rewrite the form in place
|
||||||
|
* ISSUE_FORM_LIMIT=8 bun scripts/update-issue-form.mjs
|
||||||
|
* bun scripts/update-issue-form.mjs --dry-run # print the new options, don't write
|
||||||
|
*
|
||||||
|
* Env: GITHUB_REPOSITORY (owner/repo), GH_TOKEN/GITHUB_TOKEN (for gh, provided in CI).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import {
|
||||||
|
appendFileSync,
|
||||||
|
readFileSync as read,
|
||||||
|
writeFileSync as write,
|
||||||
|
} from "node:fs";
|
||||||
|
|
||||||
|
const FORM = ".github/ISSUE_TEMPLATE/issue_report.yml";
|
||||||
|
const DROPDOWN_ID = "version"; // the `id:` of the dropdown to populate
|
||||||
|
const parsedLimit = Number.parseInt(process.env.ISSUE_FORM_LIMIT ?? "", 10);
|
||||||
|
const LIMIT =
|
||||||
|
Number.isInteger(parsedLimit) && parsedLimit > 0 ? parsedLimit : 5;
|
||||||
|
const REPO = process.env.GITHUB_REPOSITORY || "streamyfin/streamyfin";
|
||||||
|
const DRY = process.argv.includes("--dry-run");
|
||||||
|
|
||||||
|
// Matches "0.54.1" and prerelease/beta tags like "0.54.0-beta.1".
|
||||||
|
const isVersion = (s) => /^\d+\.\d+/.test(s.trim());
|
||||||
|
|
||||||
|
// 1. Fetch the latest published releases (newest first) — drafts and prereleases
|
||||||
|
// aren't a full release users run, so they don't belong in the dropdown.
|
||||||
|
const raw = execFileSync(
|
||||||
|
"gh",
|
||||||
|
[
|
||||||
|
"release",
|
||||||
|
"list",
|
||||||
|
"--repo",
|
||||||
|
REPO,
|
||||||
|
"--exclude-drafts",
|
||||||
|
"--exclude-pre-releases",
|
||||||
|
"--limit",
|
||||||
|
String(LIMIT),
|
||||||
|
"--json",
|
||||||
|
"tagName",
|
||||||
|
"--jq",
|
||||||
|
".[].tagName",
|
||||||
|
],
|
||||||
|
// Bounded timeout so a stuck gh process fails the job fast instead of
|
||||||
|
// holding the workflow open until the job-level timeout.
|
||||||
|
{ encoding: "utf8", timeout: 30_000 },
|
||||||
|
);
|
||||||
|
const seen = new Set();
|
||||||
|
const versions = [];
|
||||||
|
for (const tag of raw.split("\n")) {
|
||||||
|
if (!tag) continue;
|
||||||
|
const ver = tag.trim().replace(/^v/, "");
|
||||||
|
if (!isVersion(ver) || seen.has(ver)) continue;
|
||||||
|
seen.add(ver);
|
||||||
|
versions.push(ver);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!versions.length) {
|
||||||
|
console.error("No release versions found — leaving the form untouched.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. rewrite the dropdown options, preserving non-version sentinels
|
||||||
|
// (e.g. "older", "TestFlight/Development build") at the end of the list.
|
||||||
|
const lines = read(FORM, "utf8").split("\n");
|
||||||
|
const idIdx = lines.findIndex((l) =>
|
||||||
|
l.match(new RegExp(`^\\s*id:\\s*${DROPDOWN_ID}\\s*$`)),
|
||||||
|
);
|
||||||
|
if (idIdx === -1)
|
||||||
|
throw new Error(`dropdown id: ${DROPDOWN_ID} not found in ${FORM}`);
|
||||||
|
const optIdx = lines.findIndex(
|
||||||
|
(l, i) => i > idIdx && /^\s*options:\s*$/.test(l),
|
||||||
|
);
|
||||||
|
if (optIdx === -1)
|
||||||
|
throw new Error(`options: not found after id: ${DROPDOWN_ID}`);
|
||||||
|
|
||||||
|
const itemIndent = lines[optIdx].match(/^\s*/)[0] + " "; // options items are nested one level deeper
|
||||||
|
let end = optIdx + 1;
|
||||||
|
const sentinels = [];
|
||||||
|
while (end < lines.length && /^\s*-\s+/.test(lines[end])) {
|
||||||
|
const val = lines[end].replace(/^\s*-\s+/, "");
|
||||||
|
if (!isVersion(val)) sentinels.push(val);
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newOptions = [...versions, ...sentinels].map(
|
||||||
|
(v) => `${itemIndent}- ${v}`,
|
||||||
|
);
|
||||||
|
const updated = [
|
||||||
|
...lines.slice(0, optIdx + 1),
|
||||||
|
...newOptions,
|
||||||
|
...lines.slice(end),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Versions: ${versions.join(", ")}${sentinels.length ? ` | kept: ${sentinels.join(", ")}` : ""}`,
|
||||||
|
);
|
||||||
|
if (DRY) {
|
||||||
|
console.log("--dry-run: not writing.");
|
||||||
|
} else {
|
||||||
|
write(FORM, updated);
|
||||||
|
console.log(`Updated ${FORM}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose the resulting list for the workflow (PR description).
|
||||||
|
if (process.env.GITHUB_OUTPUT) {
|
||||||
|
appendFileSync(
|
||||||
|
process.env.GITHUB_OUTPUT,
|
||||||
|
`versions=${versions.join(", ")}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,12 +5,11 @@ import Constants from "expo-constants";
|
|||||||
* clientInfo auto-tracks the app version instead of a hardcoded string. */
|
* clientInfo auto-tracks the app version instead of a hardcoded string. */
|
||||||
export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown";
|
export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown";
|
||||||
|
|
||||||
/** Build metadata injected at build time by `app.config.ts` into `extra.build`. */
|
/** Build metadata injected at build time by `app.config.js` into `extra.build`. */
|
||||||
export interface BuildMeta {
|
export interface BuildMeta {
|
||||||
commit?: string | null;
|
commit?: string | null;
|
||||||
branch?: string | null;
|
branch?: string | null;
|
||||||
profile?: string | null;
|
profile?: string | null;
|
||||||
runNumber?: string | null;
|
|
||||||
builtAt?: string | null;
|
builtAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,10 +22,8 @@ export interface VersionInfo {
|
|||||||
commit: string | null;
|
commit: string | null;
|
||||||
/** Git branch the build was made from, e.g. "develop". */
|
/** Git branch the build was made from, e.g. "develop". */
|
||||||
branch: string | null;
|
branch: string | null;
|
||||||
/** EAS build profile, e.g. "production", "ci", "preview", or null for local. */
|
/** EAS build profile, e.g. "production", "preview", or null for local. */
|
||||||
profile: string | null;
|
profile: string | null;
|
||||||
/** GitHub Actions run number the build came from, e.g. "2098". Null outside CI. */
|
|
||||||
runNumber: string | null;
|
|
||||||
isDev: boolean;
|
isDev: boolean;
|
||||||
isProduction: boolean;
|
isProduction: boolean;
|
||||||
/** Graduated label for the Settings "App version" row (see tiering below). */
|
/** Graduated label for the Settings "App version" row (see tiering below). */
|
||||||
@@ -37,13 +34,13 @@ export interface VersionInfo {
|
|||||||
* Resolve a graduated version string for Settings.
|
* Resolve a graduated version string for Settings.
|
||||||
*
|
*
|
||||||
* Tiering (most → least detailed):
|
* Tiering (most → least detailed):
|
||||||
* - dev / local build → `version · branch · commit` (full context for debugging)
|
* - dev / local build → `version · branch · commit` (full context for debugging)
|
||||||
* - develop / CI / preview → `version · commit · #run` (pin the exact source; the
|
* - develop / CI / preview → `version · commit` (pin the exact source)
|
||||||
* Actions run number maps the build to its run — artifacts + logs — without
|
* - production (store / TestFlight) → `version (build)` (store-correlatable; the
|
||||||
* Expo access)
|
* build number lets TestFlight reports pin a build whose version isn't a
|
||||||
* - production (store / TestFlight) → `version` (build number intentionally
|
* published release. Note: TestFlight and the public App Store ship the same
|
||||||
* not shown: TestFlight already displays it to testers, and the commit pins the
|
* binary — telling them apart needs a runtime iOS receipt check, intentionally
|
||||||
* binary better)
|
* not done here.)
|
||||||
*/
|
*/
|
||||||
export function getVersionInfo(): VersionInfo {
|
export function getVersionInfo(): VersionInfo {
|
||||||
// Read native/config values defensively — a version string must never crash Settings
|
// Read native/config values defensively — a version string must never crash Settings
|
||||||
@@ -63,7 +60,6 @@ export function getVersionInfo(): VersionInfo {
|
|||||||
const commit = meta.commit ?? null;
|
const commit = meta.commit ?? null;
|
||||||
const branch = meta.branch ?? null;
|
const branch = meta.branch ?? null;
|
||||||
const profile = meta.profile ?? null;
|
const profile = meta.profile ?? null;
|
||||||
const runNumber = meta.runNumber ?? null;
|
|
||||||
const isDev = __DEV__ === true;
|
const isDev = __DEV__ === true;
|
||||||
const isProduction =
|
const isProduction =
|
||||||
typeof profile === "string" && profile.startsWith("production");
|
typeof profile === "string" && profile.startsWith("production");
|
||||||
@@ -72,12 +68,10 @@ export function getVersionInfo(): VersionInfo {
|
|||||||
if (isDev) {
|
if (isDev) {
|
||||||
display = [version ?? "dev", branch, commit].filter(Boolean).join(" · ");
|
display = [version ?? "dev", branch, commit].filter(Boolean).join(" · ");
|
||||||
} else if (isProduction) {
|
} else if (isProduction) {
|
||||||
display = version ?? build ?? "N/A";
|
|
||||||
} else {
|
|
||||||
display =
|
display =
|
||||||
[version, commit, runNumber && `#${runNumber}`]
|
version && build ? `${version} (${build})` : (version ?? build ?? "N/A");
|
||||||
.filter(Boolean)
|
} else {
|
||||||
.join(" · ") || "N/A";
|
display = [version, commit].filter(Boolean).join(" · ") || version || "N/A";
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -86,7 +80,6 @@ export function getVersionInfo(): VersionInfo {
|
|||||||
commit,
|
commit,
|
||||||
branch,
|
branch,
|
||||||
profile,
|
profile,
|
||||||
runNumber,
|
|
||||||
isDev,
|
isDev,
|
||||||
isProduction,
|
isProduction,
|
||||||
display,
|
display,
|
||||||
|
|||||||
Reference in New Issue
Block a user