diff --git a/.eas/build/android-production-apk.yml b/.eas/build/android-production-apk.yml new file mode 100644 index 000000000..757192bb6 --- /dev/null +++ b/.eas/build/android-production-apk.yml @@ -0,0 +1,25 @@ +# Custom EAS Build config for Android phone APK (downloadable artifact). +# Same bun-forcing flow as android-production.yml, but builds an APK +# (assembleRelease) instead of an AAB — for sideloading / GitHub artifact. +# Referenced from eas.json: build.production-apk.android.config +build: + name: Android phone APK (bun) + steps: + - eas/checkout + + - run: + name: Install dependencies (bun, frozen) + command: bun install --frozen-lockfile + + - run: + name: Prebuild (Android, bun) + command: bunx expo prebuild --platform android --no-install + + - eas/configure_android_version + - eas/inject_android_credentials + + - eas/run_gradle: + inputs: + command: :app:assembleRelease + + - eas/find_and_upload_build_artifacts diff --git a/.eas/build/android-production-tv.yml b/.eas/build/android-production-tv.yml new file mode 100644 index 000000000..a3fc9effe --- /dev/null +++ b/.eas/build/android-production-tv.yml @@ -0,0 +1,27 @@ +# Custom EAS Build config for Android TV APK (downloadable artifact). +# Same bun-forcing flow, with EXPO_TV=1 (set via the profile env in +# eas.json) so prebuild generates the TV variant. Builds an APK for +# sideloading onto Android TV devices. +# Referenced from eas.json: build.production-apk-tv.android.config +build: + name: Android TV APK (bun) + steps: + - eas/checkout + + - run: + name: Install dependencies (bun, frozen) + command: bun install --frozen-lockfile + + # EXPO_TV=1 comes from the profile env, so prebuild targets Android TV. + - run: + name: Prebuild (Android TV, bun) + command: bunx expo prebuild --platform android --no-install + + - eas/configure_android_version + - eas/inject_android_credentials + + - eas/run_gradle: + inputs: + command: :app:assembleRelease + + - eas/find_and_upload_build_artifacts diff --git a/.eas/build/android-production.yml b/.eas/build/android-production.yml new file mode 100644 index 000000000..651ff2b66 --- /dev/null +++ b/.eas/build/android-production.yml @@ -0,0 +1,38 @@ +# Custom EAS Build config for Android (production AAB). +# +# Why this exists: EAS's managed build can't detect Bun's text lockfile +# (bun.lock) and falls back to yarn, which breaks our install. The managed +# steps `eas/install_node_modules` and `eas/prebuild` both use "the package +# manager detected based on your project", so we replace them with explicit +# `bun` commands. Everything else uses EAS's built-in functions so we still +# get remote versioning, credentials, and artifact upload. +# +# Referenced from eas.json: build.production.android.config = android-production.yml +build: + name: Android production (bun) + steps: + - eas/checkout + + - run: + name: Install dependencies (bun, frozen) + command: bun install --frozen-lockfile + + # android/ is gitignored, so generate native code fresh. --no-install + # because deps are already installed above; bunx keeps it on bun. + - run: + name: Prebuild (Android, bun) + command: bunx expo prebuild --platform android --no-install + + # Applies the EAS-resolved remote versionCode/versionName (autoIncrement + # in eas.json) into the freshly prebuilt android/ project. + - eas/configure_android_version + + # Injects the remote Android keystore / signing config. + - eas/inject_android_credentials + + # Build the Play Store app bundle (.aab). + - eas/run_gradle: + inputs: + command: :app:bundleRelease + + - eas/find_and_upload_build_artifacts diff --git a/.eas/build/ios-production.yml b/.eas/build/ios-production.yml new file mode 100644 index 000000000..f9e228f72 --- /dev/null +++ b/.eas/build/ios-production.yml @@ -0,0 +1,44 @@ +# Custom EAS Build config for iOS + tvOS (App Store), forcing bun. +# +# Shared by both the iPhone profile (production) and the tvOS profile +# (production_tv). The profile decides the rest: +# - production_tv sets EXPO_TV=1 (env) so prebuild targets tvOS, and +# credentialsSource: local (EAS can't manage tvOS creds remotely). +# - production uses remote-managed iOS credentials. +# +# Like the Android configs, this replaces eas/install_node_modules and +# eas/prebuild (both auto-detect the wrong package manager) with explicit +# bun commands, and keeps EAS built-ins for credentials/version/fastlane. +build: + name: iOS/tvOS App Store (bun) + steps: + - eas/checkout + + - run: + name: Install dependencies (bun, frozen) + command: bun install --frozen-lockfile + + - eas/resolve_apple_team_id_from_credentials: + id: resolve_team + + # android/ + ios/ are gitignored, so generate native code fresh. + # EXPO_TV (from the profile env) selects iPhone vs tvOS. --no-install + # skips JS + pod install; we install pods explicitly below with bun deps. + - run: + name: Prebuild (iOS/tvOS, bun) + command: bunx expo prebuild --platform ios --no-install + + - run: + name: Install CocoaPods + working_directory: ./ios + command: pod install + + - eas/configure_ios_credentials + - eas/configure_ios_version + + - eas/generate_gymfile_from_template: + inputs: + credentials: ${ eas.job.secrets.buildCredentials } + + - eas/run_fastlane + - eas/find_and_upload_build_artifacts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..06dba5e37 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,216 @@ +name: 🚀 Release (EAS build + submit) + +# On merge to main (gated by the `production` GitHub Environment approval), +# build all targets on EAS in parallel via custom bun build configs: +# 1. iOS phone → App Store (auto-submit) +# 2. tvOS → App Store (auto-submit) +# 3. Android AAB → Google Play (auto-submit) +# 4. Android phone APK→ downloadable artifact +# 5. Android TV APK → downloadable artifact +# Note: EAS queues builds based on your plan's concurrency; parallel jobs +# here just submit them — EAS may still run them serially. + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + approve: + name: 🔐 Approve release + runs-on: ubuntu-24.04 + environment: production + steps: + - name: ✅ Release approved + run: echo "Release approved for ${{ github.sha }}" + + build: + name: 🚀 ${{ matrix.name }} + needs: approve + runs-on: ubuntu-24.04 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - name: 🍎 iOS + platform: ios + profile: production + submit: true + - name: 📺 tvOS + platform: ios + profile: production_tv + submit: true + - name: 🤖 Android AAB + platform: android + profile: production + submit: true + - name: 🤖 Android APK + platform: android + profile: production-apk + submit: false + artifact_name: streamyfin-android-phone-apk + - name: 📺 Android TV APK + platform: android + profile: production-apk-tv + submit: false + artifact_name: streamyfin-android-tv-apk + + steps: + - name: 📥 Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + submodules: recursive + show-progress: false + + - name: 🍞 Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: 💾 Cache Bun dependencies + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun-cache + + - name: 📦 Install dependencies and reload submodules + run: | + bun install --frozen-lockfile + bun run submodule-reload + + - name: 🏗️ Setup EAS + uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + eas-cache: true + + # tvOS uses credentialsSource: local — restore the gitignored + # credentials.json + cert + provisioning profiles from secrets. + - name: 🔐 Restore tvOS signing credentials + if: matrix.profile == 'production_tv' + env: + EAS_CREDENTIALS_JSON: ${{ secrets.EAS_CREDENTIALS_JSON }} + TVOS_DIST_CERT_P12_BASE64: ${{ secrets.TVOS_DIST_CERT_P12_BASE64 }} + TVOS_APP_PROFILE_BASE64: ${{ secrets.TVOS_APP_PROFILE_BASE64 }} + TVOS_TOPSHELF_PROFILE_BASE64: ${{ secrets.TVOS_TOPSHELF_PROFILE_BASE64 }} + run: | + mkdir -p certs profiles + printf '%s' "$EAS_CREDENTIALS_JSON" > credentials.json + echo "$TVOS_DIST_CERT_P12_BASE64" | base64 -d > certs/distribution.p12 + echo "$TVOS_APP_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_tvOS_App_Store.mobileprovision + echo "$TVOS_TOPSHELF_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_TopShelf_tvOS_App_Store.mobileprovision + + # Android Play submit needs the Google Play service account JSON. + - name: 🔐 Restore Google Play service account + if: matrix.platform == 'android' && matrix.submit + env: + GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }} + run: printf '%s' "$GOOGLE_SERVICE_ACCOUNT_KEY" > google-service-account.json + + # App Store Connect API key for iOS/tvOS submit (raw-PEM or base64). + - name: 🔐 Restore App Store Connect API key + if: matrix.platform == 'ios' + env: + APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }} + run: | + if printf '%s' "$APPLE_KEY_CONTENT" | grep -q "BEGIN PRIVATE KEY"; then + printf '%s' "$APPLE_KEY_CONTENT" > "$RUNNER_TEMP/asc_api_key.p8" + else + printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/asc_api_key.p8" + fi + + # ── Submit builds: cloud build + auto-submit to the store ── + - name: 🚀 Build & submit (${{ matrix.name }}) + if: matrix.submit + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc_api_key.p8 + EXPO_ASC_KEY_ID: ${{ secrets.APPLE_KEY_ID }} + EXPO_ASC_ISSUER_ID: ${{ secrets.APPLE_KEY_ISSUER_ID }} + run: | + eas build \ + --platform ${{ matrix.platform }} \ + --profile ${{ matrix.profile }} \ + --auto-submit \ + --non-interactive \ + --wait + + # ── Artifact builds: cloud build, then download + upload the APK ── + - name: 🏗️ Build artifact (${{ matrix.name }}) + if: ${{ !matrix.submit }} + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + eas build \ + --platform ${{ matrix.platform }} \ + --profile ${{ matrix.profile }} \ + --non-interactive \ + --wait \ + --json > build-result.json + URL=$(node -e "const b=require('./build-result.json'); const x=Array.isArray(b)?b[0]:b; console.log(x.artifacts.applicationArchiveUrl)") + echo "Downloading artifact: $URL" + curl -fL "$URL" -o "${{ matrix.artifact_name }}.apk" + + - name: 📤 Upload APK artifact (${{ matrix.name }}) + if: ${{ !matrix.submit }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.artifact_name }} + path: ${{ matrix.artifact_name }}.apk + retention-days: 14 + + # Draft a GitHub Release with the two APKs attached. The tag comes from the + # merged-in app version (app.json → expo.version), NOT the auto-incremented + # build number — so cutting a release is a deliberate version bump via PR. + github-release: + name: 📦 Draft GitHub Release + needs: build + if: ${{ !cancelled() }} + runs-on: ubuntu-24.04 + permissions: + contents: write + actions: read # required for `gh run download` to list/fetch this run's artifacts + steps: + - name: 📥 Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + show-progress: false + + - name: 📦 Download APK artifacts from this run + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p apks + gh run download ${{ github.run_id }} --name streamyfin-android-phone-apk --dir apks + gh run download ${{ github.run_id }} --name streamyfin-android-tv-apk --dir apks + ls -la apks + + - name: 📝 Draft release (tag = app.json version, not auto-bumped) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION=$(node -e "console.log(require('./app.json').expo.version)") + TAG="v$VERSION" + echo "Release tag from merged app version: $TAG" + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG exists — updating APK assets" + gh release upload "$TAG" apks/*.apk --clobber + else + echo "Creating draft release $TAG" + gh release create "$TAG" \ + --draft \ + --generate-notes \ + --title "$TAG" \ + apks/*.apk + fi diff --git a/.gitignore b/.gitignore index c39e191b9..ef41769f9 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ web-build/ /androidmobile /androidtv +# Gradle caches (top-level + per-module native projects) +**/.gradle/ + # Module-specific Builds modules/mpv-player/android/build modules/player/android @@ -73,6 +76,12 @@ modules/background-downloader/android/build/* # ios:unsigned-build Artifacts build/ +# but keep EAS custom build configs (the generic build/ rule above matches .eas/build/) +!.eas/build/ +!.eas/build/** .claude/ .agents/skills/** skills-lock.json + +# CI-injected Google Play service account key (written at build time) +google-service-account.json diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index 884b1fbb2..da4a8272c 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -1,4 +1,9 @@ -import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetView, +} from "@gorhom/bottom-sheet"; import { useNavigation } from "expo-router"; import { useAtom } from "jotai"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -7,6 +12,7 @@ import { Alert, Platform, ScrollView, View } from "react-native"; import { Pressable } from "react-native-gesture-handler"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { toast } from "sonner-native"; +import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import ActiveDownloads from "@/components/downloads/ActiveDownloads"; @@ -101,7 +107,7 @@ export default function DownloadsPage() { navigation.setOptions({ headerRight: () => ( bottomSheetModalRef.current?.present()} className='px-2' > f.item) || []} /> @@ -116,7 +122,7 @@ export default function DownloadsPage() { } }, [showMigration]); - const _deleteMovies = () => + const deleteMovies = () => deleteFileByType("Movie") .then(() => toast.success( @@ -127,7 +133,7 @@ export default function DownloadsPage() { writeToLog("ERROR", reason); toast.error(t("home.downloads.toasts.failed_to_delete_all_movies")); }); - const _deleteShows = () => + const deleteShows = () => deleteFileByType("Episode") .then(() => toast.success( @@ -138,7 +144,7 @@ export default function DownloadsPage() { writeToLog("ERROR", reason); toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries")); }); - const _deleteOtherMedia = () => + const deleteOtherMedia = () => Promise.all( otherMedia .filter((item) => item.item.Type) @@ -162,6 +168,9 @@ export default function DownloadsPage() { ), ); + const deleteAllMedia = async () => + await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]); + return ( + ( + + )} + > + + + + + {otherMedia.length > 0 && ( + + )} + + + + ); } diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index db223b2bf..69b980d3e 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -59,17 +59,19 @@ function SettingsMobile() { - - - - router.push("/(auth)/(tabs)/(home)/companion-login") - } - title={t("pairing.pair_with_phone")} - textColor='blue' - /> - - + {Platform.OS !== "ios" && ( + + + + router.push("/(auth)/(tabs)/(home)/companion-login") + } + title={t("pairing.pair_with_phone")} + textColor='blue' + /> + + + )} diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx index 1c4dcd199..8f0a2c931 100644 --- a/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx @@ -114,7 +114,7 @@ export default function StreamystatsPage() { }; const handleRefreshFromServer = useCallback(async () => { - const newPluginSettings = await refreshStreamyfinPluginSettings(true); + const newPluginSettings = await refreshStreamyfinPluginSettings(); // Update local state with new values const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || ""; setUrl(newUrl); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx index 519d5e5cc..214564ebb 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/page.tsx @@ -6,6 +6,7 @@ import { BottomSheetTextInput, BottomSheetView, } from "@gorhom/bottom-sheet"; +import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; @@ -76,7 +77,7 @@ const MobilePage: React.FC = () => { const [issueMessage, setIssueMessage] = useState(); const [requestBody, _setRequestBody] = useState(); const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false); - const advancedReqModalRef = useRef(null); + const advancedReqModalRef = useRef(null); const bottomSheetModalRef = useRef(null); const { diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx index acc7f8173..ed31b438f 100644 --- a/app/(auth)/(tabs)/(libraries)/_layout.tsx +++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx @@ -166,7 +166,7 @@ export default function IndexLayout() { open={dropdownOpen} onOpenChange={setDropdownOpen} trigger={ - + { keyboardDismissMode='none' screenOptions={{ tabBarBounces: true, + tabBarActiveTintColor: "#FFFFFF", + tabBarInactiveTintColor: "#9CA3AF", tabBarLabelStyle: { fontSize: TAB_LABEL_FONT_SIZE, fontWeight: "600", diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index bebf797f5..b6598537c 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -291,6 +291,11 @@ export default function DirectPlayerPage() { }; if (itemId) { + setItem(null); + setDownloadedItem(null); + // Clear the previous episode's stream so the loader gate stays closed + // until the new item's stream resolves (avoids a stale MPV source frame). + setStream(null); fetchItemData(); } }, [itemId, offline, api, user?.Id]); @@ -333,6 +338,12 @@ export default function DirectPlayerPage() { return null; } + // Ensure item matches the current itemId to avoid race conditions + if (item.Id !== itemId) { + setStreamStatus({ isLoading: false, isError: false }); + return null; + } + let result: Stream | null = null; if (offline && downloadedItem?.mediaSource) { const url = downloadedItem.videoFilePath; @@ -405,6 +416,7 @@ export default function DirectPlayerPage() { item, user?.Id, downloadedItem, + offline, ]); useEffect(() => { @@ -538,21 +550,15 @@ export default function DirectPlayerPage() { if (!item?.Id || !stream?.sessionId || offline || !api) return; const currentTimeInTicks = msToTicks(progress.get()); - await getPlaystateApi(api).onPlaybackStopped({ - itemId: item.Id, - mediaSourceId: mediaSourceId, - positionTicks: currentTimeInTicks, - playSessionId: stream.sessionId, + await getPlaystateApi(api).reportPlaybackStopped({ + playbackStopInfo: { + ItemId: item.Id, + MediaSourceId: mediaSourceId, + PositionTicks: currentTimeInTicks, + PlaySessionId: stream.sessionId, + }, }); - }, [ - api, - item, - mediaSourceId, - stream, - progress, - offline, - revalidateProgressCache, - ]); + }, [api, item, mediaSourceId, stream, progress, offline]); const stop = useCallback(() => { // Update URL with final playback position before stopping @@ -570,9 +576,10 @@ export default function DirectPlayerPage() { useEffect(() => { const beforeRemoveListener = navigation.addListener("beforeRemove", stop); return () => { + reportPlaybackStopped(); beforeRemoveListener(); }; - }, [navigation, stop]); + }, [navigation, stop, reportPlaybackStopped]); const currentPlayStateInfo = useCallback((): | PlaybackProgressInfo diff --git a/bun.lock b/bun.lock index 567a08021..ba54eecdb 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "@expo/react-native-action-sheet": "^4.1.1", "@expo/ui": "~56.0.14", "@expo/vector-icons": "^15.0.3", - "@gorhom/bottom-sheet": "5.2.8", + "@gorhom/bottom-sheet": "5.2.14", "@jellyfin/sdk": "^0.13.0", "@react-native-community/netinfo": "^12.0.0", "@react-navigation/material-top-tabs": "7.4.28", @@ -364,7 +364,7 @@ "@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="], - "@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.8", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA=="], + "@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.14", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-uLQFlDjp9z+jrOFcMSEldPqL5JdaXL3vXOh+juhwoNvXgTsEorJLjHTugXu+YccAG/0KJnShzKCrb71MHBsvJg=="], "@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="], diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx index aaea71b3f..5487393dd 100644 --- a/components/PlatformDropdown.tsx +++ b/components/PlatformDropdown.tsx @@ -1,13 +1,7 @@ import { Ionicons } from "@expo/vector-icons"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; -import React, { useEffect, useState } from "react"; -import { - type LayoutChangeEvent, - Platform, - StyleSheet, - TouchableOpacity, - View, -} from "react-native"; +import React, { useEffect } from "react"; +import { Platform, StyleSheet, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { useGlobalModal } from "@/providers/GlobalModalProvider"; @@ -217,24 +211,6 @@ const PlatformDropdownComponent = ({ }: PlatformDropdownProps) => { const { showModal, hideModal, isVisible } = useGlobalModal(); - // @expo/ui's (SDK 55) fills its available space by default, and - // `matchContents` doesn't help here: it reports the native Menu's size via - // setStyleSize and overrides any explicit size. Instead we measure the - // trigger's intrinsic size in plain RN (off-layout) and pin it on the Host. - const [triggerSize, setTriggerSize] = useState<{ - width: number; - height: number; - } | null>(null); - - const handleMeasureTrigger = (e: LayoutChangeEvent) => { - const { width, height } = e.nativeEvent.layout; - setTriggerSize((prev) => - prev && prev.width === width && prev.height === height - ? prev - : { width, height }, - ); - }; - // Handle controlled open state for Android useEffect(() => { if (Platform.OS === "android" && controlledOpen === true) { @@ -265,25 +241,11 @@ const PlatformDropdownComponent = ({ }, [isVisible, controlledOpen, controlledOnOpenChange]); if (Platform.OS === "ios" && !Platform.isTV) { - // Pin the wrapper to the measured trigger size. @expo/ui's (SDK 55) - // fills its parent and reports its own size via setStyleSize, so it can't - // size itself to content. If the wrapper has no size, the Host's `flex: 1` - // height depends on the parent while the parent depends on the Host — a - // circular dependency that collapses to 0 for any selector nested more than - // one level deep (so only the first, shallowest dropdown stays visible). - // Giving the wrapper the measured size breaks the cycle; the Host then - // fills a concrete box. + // @expo/ui's can't size to content, so an in-flow invisible copy of + // the trigger sizes the wrapper while the Host overlays the real Menu. return ( - - {/* Hidden measurer: lays the trigger out off-flow to capture its - intrinsic size. Absolutely positioned WITHOUT right/bottom so it - sizes to the trigger's content rather than to its parent. */} - + + {trigger} diff --git a/components/chapters/ChapterList.tsx b/components/chapters/ChapterList.tsx index 42a90b89e..e44332095 100644 --- a/components/chapters/ChapterList.tsx +++ b/components/chapters/ChapterList.tsx @@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next"; import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native"; import { Text } from "@/components/common/Text"; import { Colors } from "@/constants/Colors"; +import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets"; import { type ChapterEntry, chapterStartsMs, @@ -38,6 +39,7 @@ function ChapterListComponent({ onClose, }: ChapterListProps) { const { t } = useTranslation(); + const safeArea = useControlsSafeAreaInsets(); const listRef = useRef>(null); const entries = useMemo(() => sortedChapters(chapters), [chapters]); @@ -74,9 +76,22 @@ function ChapterListComponent({ transparent animationType='slide' onRequestClose={onClose} + // iOS defaults to portrait-only; without this it rotates the app + // back to portrait when opened from the landscape player. Android ignores it. + supportedOrientations={["portrait", "landscape"]} > - e.stopPropagation()} style={styles.sheet}> + e.stopPropagation()} + style={[ + styles.sheet, + { + marginLeft: safeArea.left, + marginRight: safeArea.right, + paddingBottom: safeArea.bottom, + }, + ]} + > {t("chapters.title")} = ({ item }) => { } /> ); diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx index cc40d2dc7..fed45dc99 100644 --- a/components/common/TouchableItemRouter.tsx +++ b/components/common/TouchableItemRouter.tsx @@ -2,6 +2,7 @@ import { useActionSheet } from "@expo/react-native-action-sheet"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useSegments } from "expo-router"; import { type PropsWithChildren, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import { Platform, TouchableOpacity, @@ -149,6 +150,7 @@ export const TouchableItemRouter: React.FC> = ({ children, ...props }) => { + const { t } = useTranslation(); const segments = useSegments(); const { showActionSheetWithOptions } = useActionSheet(); const markAsPlayedStatus = useMarkAsPlayed([item]); @@ -182,11 +184,13 @@ export const TouchableItemRouter: React.FC> = ({ return; const options: string[] = [ - "Mark as Played", - "Mark as Not Played", - isFavorite ? "Unmark as Favorite" : "Mark as Favorite", - ...(isOffline ? ["Delete Download"] : []), - "Cancel", + t("common.mark_as_played"), + t("common.mark_as_not_played"), + isFavorite + ? t("music.track_options.remove_from_favorites") + : t("music.track_options.add_to_favorites"), + ...(isOffline ? [t("home.downloads.delete_download")] : []), + t("common.cancel"), ]; const cancelButtonIndex = options.length - 1; const destructiveButtonIndex = isOffline @@ -219,6 +223,7 @@ export const TouchableItemRouter: React.FC> = ({ isOffline, deleteFile, item.Id, + t, ]); if ( diff --git a/components/home/Home.tsx b/components/home/Home.tsx index 637e20418..060dafedb 100644 --- a/components/home/Home.tsx +++ b/components/home/Home.tsx @@ -133,7 +133,6 @@ const HomeMobile = () => { onPress={() => { router.push("/(auth)/downloads"); }} - className='ml-1.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > = ({ item, ...props }) => { return { ...item, People: people } as BaseItemDto; }, [item, people]); - const topPeople = useMemo(() => people.slice(0, 3), [people]); + // Jellyfin can list the same person several times (e.g. an actor also + // credited as writer). Dedupe by Id so the same actor section isn't rendered + // twice and we still surface 3 distinct people. + const topPeople = useMemo(() => { + const seen = new Set(); + const unique: BaseItemPerson[] = []; + for (const person of people) { + if (!person.Id || seen.has(person.Id)) continue; + seen.add(person.Id); + unique.push(person); + if (unique.length >= 3) break; + } + return unique; + }, [people]); const renderActorSection = useCallback( (person: BaseItemPerson, idx: number, total: number) => { diff --git a/components/login/TVAddServerForm.tsx b/components/login/TVAddServerForm.tsx index 8d0168b23..cf38fa54c 100644 --- a/components/login/TVAddServerForm.tsx +++ b/components/login/TVAddServerForm.tsx @@ -1,6 +1,6 @@ import { t } from "i18next"; import React, { useCallback, useState } from "react"; -import { ScrollView, View } from "react-native"; +import { Platform, ScrollView, View } from "react-native"; import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { useScaledTVTypography } from "@/constants/TVTypography"; @@ -107,7 +107,7 @@ export const TVAddServerForm: React.FC = ({ {/* Pair with Phone */} - {onStartPairing && ( + {Platform.OS !== "ios" && onStartPairing && (