diff --git a/.github/ISSUE_TEMPLATE/issue_report.yml b/.github/ISSUE_TEMPLATE/issue_report.yml
index af86644d..365afca2 100644
--- a/.github/ISSUE_TEMPLATE/issue_report.yml
+++ b/.github/ISSUE_TEMPLATE/issue_report.yml
@@ -75,10 +75,13 @@ body:
id: version
attributes:
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:
- 0.54.1
- 0.51.0
+ - 0.47.1
+ - 0.30.2
+ - 0.28.0
- Older
- TestFlight/Development build
validations:
diff --git a/.github/workflows/update-issue-form.yml b/.github/workflows/update-issue-form.yml
index a23ecdf2..8b5af9c8 100644
--- a/.github/workflows/update-issue-form.yml
+++ b/.github/workflows/update-issue-form.yml
@@ -1,67 +1,102 @@
-name: ๐ Update Bug Report Template
+name: ๐ Update Issue Form Versions
on:
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:
- group: update-issue-form-${{ github.event.release.tag_name || github.run_id }}
- cancel-in-progress: true
+ group: update-issue-form
+ cancel-in-progress: false
+
+permissions:
+ contents: read
jobs:
- update-bug-report:
+ update-issue-form:
+ name: ๐ข Populate version dropdown
+ runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
- issues: write
- runs-on: ubuntu-24.04
-
steps:
- name: ๐ฅ Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
-
- - name: "๐ข Setup Node.js"
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
- node-version: '24.x'
- cache: 'npm'
+ # On `release` events GITHUB_SHA is the tagged commit โ without this the
+ # 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
- id: minor
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
+ - name: ๐ Setup Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
- result-encoding: string
- 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();
+ bun-version: latest
- - name: ๐ Update bug report version
- uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
- with:
- semver: '^0.${{ steps.minor.outputs.result }}.0'
- dry_run: no-push
+ - name: ๐ข Populate version dropdown from GitHub releases
+ id: populate
+ run: bun scripts/update-issue-form.mjs
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
- - name: โ๏ธ Update bug report node version dropdown
- uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
- with:
- dropdown: _node_version
- package: node
- semver: '>=24.0.0'
- dry_run: no-push
-
- - name: ๐ฌ Commit and create pull request
+ - name: ๐ฌ Create pull request
+ id: cpr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
- add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
- branch: ci-update-bug-report
+ add-paths: .github/ISSUE_TEMPLATE/issue_report.yml
+ branch: ci/update-issue-form
base: develop
delete-branch: true
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: |
- Automated update to `.github/ISSUE_TEMPLATE/bug_report.yml`
- Triggered by workflow run [${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
+ # ๐ฆ Pull Request
+
+ ## ๐ 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."
diff --git a/GLOBAL_MODAL_GUIDE.md b/GLOBAL_MODAL_GUIDE.md
index 5426ca72..11a05f10 100644
--- a/GLOBAL_MODAL_GUIDE.md
+++ b/GLOBAL_MODAL_GUIDE.md
@@ -143,14 +143,6 @@ interface ModalOptions {
}
```
-## Examples
-
-See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
-- Simple content modal
-- Modal with custom snap points
-- Complex component in modal
-- Success/error modals triggered from functions
-
## Default Styling
The modal uses these default styles (can be overridden via options):
diff --git a/augmentations/index.ts b/augmentations/index.ts
index abec02c9..0c193e83 100644
--- a/augmentations/index.ts
+++ b/augmentations/index.ts
@@ -1,4 +1,3 @@
export * from "./api";
export * from "./mmkv";
export * from "./number";
-export * from "./string";
diff --git a/augmentations/number.ts b/augmentations/number.ts
index bef44ac5..c8146d65 100644
--- a/augmentations/number.ts
+++ b/augmentations/number.ts
@@ -3,7 +3,6 @@ declare global {
bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number;
minutesToMilliseconds(): number;
- hoursToMilliseconds(): number;
}
}
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds();
};
-Number.prototype.hoursToMilliseconds = function () {
- return this.valueOf() * (60).minutesToMilliseconds();
-};
-
export {};
diff --git a/augmentations/string.ts b/augmentations/string.ts
deleted file mode 100644
index f4a50b55..00000000
--- a/augmentations/string.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-declare global {
- interface String {
- toTitle(): string;
- }
-}
-
-String.prototype.toTitle = function () {
- return this.replaceAll("_", " ").replace(
- /\w\S*/g,
- (text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
- );
-};
-
-export {};
diff --git a/components/ContextMenu.tv.ts b/components/ContextMenu.tv.ts
deleted file mode 100644
index e69de29b..00000000
diff --git a/components/ExampleGlobalModalUsage.tsx b/components/ExampleGlobalModalUsage.tsx
deleted file mode 100644
index ccebb823..00000000
--- a/components/ExampleGlobalModalUsage.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-/**
- * Example Usage of Global Modal
- *
- * This file demonstrates how to use the global modal system from anywhere in your app.
- * You can delete this file after understanding how it works.
- */
-
-import { Ionicons } from "@expo/vector-icons";
-import { TouchableOpacity, View } from "react-native";
-import { Text } from "@/components/common/Text";
-import { useGlobalModal } from "@/providers/GlobalModalProvider";
-
-/**
- * Example 1: Simple Content Modal
- */
-export const SimpleModalExample = () => {
- const { showModal } = useGlobalModal();
-
- const handleOpenModal = () => {
- showModal(
-
- Simple Modal
-
- This is a simple modal with just some text content.
-
-
- Swipe down or tap outside to close.
-
- ,
- );
- };
-
- return (
-
- Open Simple Modal
-
- );
-};
-
-/**
- * Example 2: Modal with Custom Snap Points
- */
-export const CustomSnapPointsExample = () => {
- const { showModal } = useGlobalModal();
-
- const handleOpenModal = () => {
- showModal(
-
-
- Custom Snap Points
-
-
- This modal has custom snap points (25%, 50%, 90%).
-
-
-
- Try dragging the modal to different heights!
-
-
- ,
- {
- snapPoints: ["25%", "50%", "90%"],
- enableDynamicSizing: false,
- },
- );
- };
-
- return (
-
- Custom Snap Points
-
- );
-};
-
-/**
- * Example 3: Complex Component in Modal
- */
-const SettingsModalContent = () => {
- const { hideModal } = useGlobalModal();
-
- const settings = [
- {
- id: 1,
- title: "Notifications",
- icon: "notifications-outline" as const,
- enabled: true,
- },
- { id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
- {
- id: 3,
- title: "Auto-play",
- icon: "play-outline" as const,
- enabled: false,
- },
- ];
-
- return (
-
- Settings
-
- {settings.map((setting, index) => (
-
-
-
- {setting.title}
-
-
-
-
-
- ))}
-
-
- Close
-
-
- );
-};
-
-export const ComplexModalExample = () => {
- const { showModal } = useGlobalModal();
-
- const handleOpenModal = () => {
- showModal();
- };
-
- return (
-
- Complex Component
-
- );
-};
-
-/**
- * Example 4: Modal Triggered from Function (e.g., API response)
- */
-export const useShowSuccessModal = () => {
- const { showModal } = useGlobalModal();
-
- return (message: string) => {
- showModal(
-
-
-
-
- Success!
- {message}
- ,
- );
- };
-};
-
-/**
- * Main Demo Component
- */
-export const GlobalModalDemo = () => {
- const showSuccess = useShowSuccessModal();
-
- return (
-
-
- Global Modal Examples
-
-
-
-
-
-
- showSuccess("Operation completed successfully!")}
- className='bg-orange-600 px-4 py-2 rounded-lg'
- >
- Show Success Modal
-
-
- );
-};
diff --git a/components/TrackSheet.tsx b/components/TrackSheet.tsx
index 87cdacbc..7628d221 100644
--- a/components/TrackSheet.tsx
+++ b/components/TrackSheet.tsx
@@ -27,7 +27,7 @@ export const TrackSheet: React.FC = ({
const streams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
- [source],
+ [source, streamType],
);
const selectedSteam = useMemo(
diff --git a/components/common/LargePoster.tsx b/components/common/LargePoster.tsx
deleted file mode 100644
index ab3b16fb..00000000
--- a/components/common/LargePoster.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Image } from "expo-image";
-import { View } from "react-native";
-
-export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
- if (!url)
- return (
-
-
-
- );
-
- return (
-
-
-
- );
-};
diff --git a/components/common/VerticalSkeleton.tsx b/components/common/VerticalSkeleton.tsx
deleted file mode 100644
index 02a8a256..00000000
--- a/components/common/VerticalSkeleton.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { View, type ViewProps } from "react-native";
-
-interface Props extends ViewProps {
- index: number;
-}
-
-export const VerticalSkeleton: React.FC = ({ index, ...props }) => {
- return (
-
-
-
-
-
-
- );
-};
diff --git a/components/library/LibraryItemCard.tsx b/components/library/LibraryItemCard.tsx
index 09de500d..c84364ce 100644
--- a/components/library/LibraryItemCard.tsx
+++ b/components/library/LibraryItemCard.tsx
@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => {
api,
item: library,
}),
- [library],
+ [api, library],
);
const itemType = useMemo(() => {
diff --git a/components/navigation/TabBarIcon.tsx b/components/navigation/TabBarIcon.tsx
deleted file mode 100644
index a28bba84..00000000
--- a/components/navigation/TabBarIcon.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
-
-import type { IconProps } from "@expo/vector-icons/build/createIconSet";
-import Ionicons from "@expo/vector-icons/Ionicons";
-import type { ComponentProps } from "react";
-
-export function TabBarIcon({
- style,
- ...rest
-}: IconProps["name"]>) {
- return ;
-}
diff --git a/components/posters/EpisodePoster.tsx b/components/posters/EpisodePoster.tsx
deleted file mode 100644
index af42989b..00000000
--- a/components/posters/EpisodePoster.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { Image } from "expo-image";
-import { useAtom } from "jotai";
-import { useMemo, useState } from "react";
-import { View } from "react-native";
-import { WatchedIndicator } from "@/components/WatchedIndicator";
-import { apiAtom } from "@/providers/JellyfinProvider";
-
-type MoviePosterProps = {
- item: BaseItemDto;
- showProgress?: boolean;
-};
-
-export const EpisodePoster: React.FC = ({
- item,
- showProgress = false,
-}) => {
- const [api] = useAtom(apiAtom);
-
- const url = useMemo(() => {
- if (item.Type === "Episode") {
- return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
- }
- }, [item]);
-
- const [progress, _setProgress] = useState(
- item.UserData?.PlayedPercentage || 0,
- );
-
- const blurhash = useMemo(() => {
- const key = item.ImageTags?.Primary as string;
- return item.ImageBlurHashes?.Primary?.[key];
- }, [item]);
-
- return (
-
-
-
- {showProgress && progress > 0 && (
-
- )}
-
- );
-};
diff --git a/components/posters/ParentPoster.tsx b/components/posters/ParentPoster.tsx
deleted file mode 100644
index 47b62e4c..00000000
--- a/components/posters/ParentPoster.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Image } from "expo-image";
-import { useAtom } from "jotai";
-import { useMemo } from "react";
-import { View } from "react-native";
-import { apiAtom } from "@/providers/JellyfinProvider";
-
-type PosterProps = {
- id?: string;
- showProgress?: boolean;
-};
-
-const ParentPoster: React.FC = ({ id }) => {
- const [api] = useAtom(apiAtom);
-
- const url = useMemo(
- () => `${api?.basePath}/Items/${id}/Images/Primary`,
- [id],
- );
-
- if (!url || !id)
- return (
-
- );
-
- return (
-
-
-
- );
-};
-
-export default ParentPoster;
diff --git a/components/settings/Dashboard.tsx b/components/settings/Dashboard.tsx
deleted file mode 100644
index d41de008..00000000
--- a/components/settings/Dashboard.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { useTranslation } from "react-i18next";
-import { View } from "react-native";
-import useRouter from "@/hooks/useAppRouter";
-import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
-import { useSettings } from "@/utils/atoms/settings";
-import { ListGroup } from "../list/ListGroup";
-import { ListItem } from "../list/ListItem";
-
-export const Dashboard = () => {
- const { settings } = useSettings();
- const { sessions = [] } = useSessions({} as useSessionsProps);
- const router = useRouter();
-
- const { t } = useTranslation();
-
- if (!settings) return null;
- return (
-
-
- router.push("/settings/dashboard/sessions")}
- title={t("home.settings.dashboard.sessions_title")}
- showArrow
- />
-
-
- );
-};
diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx
deleted file mode 100644
index 3a0017ac..00000000
--- a/components/settings/DownloadSettings.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function DownloadSettings() {
- return null;
-}
diff --git a/components/settings/DownloadSettings.tv.tsx b/components/settings/DownloadSettings.tv.tsx
deleted file mode 100644
index 3a0017ac..00000000
--- a/components/settings/DownloadSettings.tv.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function DownloadSettings() {
- return null;
-}
diff --git a/components/settings/Jellyseerr.tsx b/components/settings/Jellyseerr.tsx
index 470d40a2..436b46c4 100644
--- a/components/settings/Jellyseerr.tsx
+++ b/components/settings/Jellyseerr.tsx
@@ -115,9 +115,6 @@ export const JellyseerrSettings = () => {
>
) : (
-
- {t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
-
{t("home.settings.plugins.jellyseerr.server_url")}
diff --git a/constants/Languages.ts b/constants/Languages.ts
deleted file mode 100644
index 8014e380..00000000
--- a/constants/Languages.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import type { DefaultLanguageOption } from "@/utils/atoms/settings";
-
-export const LANGUAGES: DefaultLanguageOption[] = [
- { label: "English", value: "eng" },
- { label: "Spanish", value: "spa" },
- { label: "Chinese (Mandarin)", value: "cmn" },
- { label: "Hindi", value: "hin" },
- { label: "Arabic", value: "ara" },
- { label: "French", value: "fra" },
- { label: "Russian", value: "rus" },
- { label: "Portuguese", value: "por" },
- { label: "Japanese", value: "jpn" },
- { label: "German", value: "deu" },
- { label: "Italian", value: "ita" },
- { label: "Korean", value: "kor" },
- { label: "Turkish", value: "tur" },
- { label: "Dutch", value: "nld" },
- { label: "Polish", value: "pol" },
- { label: "Vietnamese", value: "vie" },
- { label: "Thai", value: "tha" },
- { label: "Indonesian", value: "ind" },
- { label: "Greek", value: "ell" },
- { label: "Swedish", value: "swe" },
- { label: "Danish", value: "dan" },
- { label: "Norwegian", value: "nor" },
- { label: "Finnish", value: "fin" },
- { label: "Czech", value: "ces" },
- { label: "Hungarian", value: "hun" },
- { label: "Romanian", value: "ron" },
- { label: "Ukrainian", value: "ukr" },
- { label: "Hebrew", value: "heb" },
- { label: "Bengali", value: "ben" },
- { label: "Punjabi", value: "pan" },
- { label: "Tagalog", value: "tgl" },
- { label: "Swahili", value: "swa" },
- { label: "Malay", value: "msa" },
- { label: "Persian", value: "fas" },
- { label: "Urdu", value: "urd" },
-];
diff --git a/hooks/useControlsVisibility.ts b/hooks/useControlsVisibility.ts
deleted file mode 100644
index caca0d84..00000000
--- a/hooks/useControlsVisibility.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { useCallback, useEffect, useRef } from "react";
-import { useSharedValue } from "react-native-reanimated";
-
-export const useControlsVisibility = (timeout = 3000) => {
- const opacity = useSharedValue(1);
-
- const hideControlsTimerRef = useRef | null>(
- null,
- );
-
- const showControls = useCallback(() => {
- opacity.value = 1;
- if (hideControlsTimerRef.current) {
- clearTimeout(hideControlsTimerRef.current);
- }
- hideControlsTimerRef.current = setTimeout(() => {
- opacity.value = 0;
- }, timeout);
- }, [timeout]);
-
- const hideControls = useCallback(() => {
- opacity.value = 0;
- if (hideControlsTimerRef.current) {
- clearTimeout(hideControlsTimerRef.current);
- }
- }, []);
-
- useEffect(() => {
- return () => {
- if (hideControlsTimerRef.current) {
- clearTimeout(hideControlsTimerRef.current);
- }
- };
- }, []);
-
- return { opacity, showControls, hideControls };
-};
diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts
deleted file mode 100644
index 845161a1..00000000
--- a/hooks/useDownloadedFileOpener.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
-import { useCallback } from "react";
-import useRouter from "@/hooks/useAppRouter";
-import { usePlaySettings } from "@/providers/PlaySettingsProvider";
-import { writeToLog } from "@/utils/log";
-
-export const useDownloadedFileOpener = () => {
- const router = useRouter();
- const { setPlayUrl, setOfflineSettings } = usePlaySettings();
-
- const openFile = useCallback(
- async (item: BaseItemDto) => {
- if (!item.Id) {
- writeToLog("ERROR", "Attempted to open a file without an ID.");
- console.error("Attempted to open a file without an ID.");
- return;
- }
- const queryParams = new URLSearchParams({
- itemId: item.Id,
- offline: "true",
- playbackPosition:
- item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
- });
- try {
- router.push(`/player/direct-player?${queryParams.toString()}`);
- } catch (error) {
- writeToLog("ERROR", "Error opening file", error);
- console.error("Error opening file:", error);
- }
- },
- [setOfflineSettings, setPlayUrl, router],
- );
-
- return { openFile };
-};
diff --git a/hooks/useImageColors.ts b/hooks/useImageColors.ts
deleted file mode 100644
index 4d8a0136..00000000
--- a/hooks/useImageColors.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
-import { useAtom, useAtomValue } from "jotai";
-import { useEffect, useMemo } from "react";
-import { Platform } from "react-native";
-import type * as ImageColorsType from "react-native-image-colors";
-import { apiAtom } from "@/providers/JellyfinProvider";
-
-// Conditionally import react-native-image-colors only on non-TV platforms
-const ImageColors = Platform.isTV
- ? null
- : (require("react-native-image-colors") as typeof ImageColorsType);
-
-import {
- adjustToNearBlack,
- calculateTextColor,
- isCloseToBlack,
- itemThemeColorAtom,
-} from "@/utils/atoms/primaryColor";
-import { getItemImage } from "@/utils/getItemImage";
-import { storage } from "@/utils/mmkv";
-
-/**
- * Custom hook to extract and manage image colors for a given item.
- *
- * @param item - The BaseItemDto object representing the item.
- * @param disabled - A boolean flag to disable color extraction.
- *
- */
-export const useImageColors = ({
- item,
- url,
- disabled,
-}: {
- item?: BaseItemDto | null;
- url?: string | null;
- disabled?: boolean;
-}) => {
- const api = useAtomValue(apiAtom);
- const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
-
- const isTv = Platform.isTV;
-
- const source = useMemo(() => {
- if (!api) return;
- if (url) return { uri: url };
- if (item)
- return getItemImage({
- item,
- api,
- variant: "Primary",
- quality: 80,
- width: 300,
- });
- return null;
- }, [api, item, url]);
-
- useEffect(() => {
- if (isTv) return;
- if (disabled) return;
- if (source?.uri) {
- const _primary = storage.getString(`${source.uri}-primary`);
- const _text = storage.getString(`${source.uri}-text`);
-
- if (_primary && _text) {
- setPrimaryColor({
- primary: _primary,
- text: _text,
- });
- return;
- }
-
- // Extract colors from the image
- if (!ImageColors?.getColors) return;
-
- ImageColors.getColors(source.uri, {
- fallback: "#fff",
- cache: false,
- })
- .then((colors: ImageColorsType.ImageColorsResult) => {
- let primary = "#fff";
- let text = "#000";
- let backup = "#fff";
-
- // Select the appropriate color based on the platform
- if (colors.platform === "android") {
- primary = colors.dominant;
- backup = colors.vibrant;
- } else if (colors.platform === "ios") {
- primary = colors.detail;
- backup = colors.primary;
- }
-
- // Adjust the primary color if it's too close to black
- if (primary && isCloseToBlack(primary)) {
- if (backup && !isCloseToBlack(backup)) primary = backup;
- primary = adjustToNearBlack(primary);
- }
-
- // Calculate the text color based on the primary color
- if (primary) text = calculateTextColor(primary);
-
- setPrimaryColor({
- primary,
- text,
- });
-
- // Cache the colors in storage
- if (source.uri && primary) {
- storage.set(`${source.uri}-primary`, primary);
- storage.set(`${source.uri}-text`, text);
- }
- })
- .catch((error: any) => {
- console.error("Error getting colors", error);
- });
- }
- }, [isTv, source?.uri, setPrimaryColor, disabled]);
-
- if (isTv) return;
-};
diff --git a/hooks/useWifiSSID.ts b/hooks/useWifiSSID.ts
index 2b442a58..de0e2828 100644
--- a/hooks/useWifiSSID.ts
+++ b/hooks/useWifiSSID.ts
@@ -53,7 +53,6 @@ export function useWifiSSID(): UseWifiSSIDReturn {
const fetchSSID = useCallback(async () => {
if (Platform.isTV) return;
const result = await getSSID();
- console.log("[WiFi Debug] Native module SSID:", result);
setSSID(result);
}, []);
diff --git a/modules/wifi-ssid/index.ts b/modules/wifi-ssid/index.ts
index 71bfbcfb..00d2bf83 100644
--- a/modules/wifi-ssid/index.ts
+++ b/modules/wifi-ssid/index.ts
@@ -15,7 +15,6 @@ const WifiSsidModule =
*/
export async function getSSID(): Promise {
if (!WifiSsidModule) {
- console.log("[WifiSsid] Module not available on this platform");
return null;
}
diff --git a/providers/Downloads/hooks/useDownloadEventHandlers.ts b/providers/Downloads/hooks/useDownloadEventHandlers.ts
index ebf8b116..0bc8c352 100644
--- a/providers/Downloads/hooks/useDownloadEventHandlers.ts
+++ b/providers/Downloads/hooks/useDownloadEventHandlers.ts
@@ -142,31 +142,12 @@ export function useDownloadEventHandlers({
} else {
// Transcoding - estimate from bitrate
const process = processes.find((p) => p.id === processId);
- console.log(
- `[DPL] Transcoding detected, looking for process ${processId}, found:`,
- process ? "yes" : "no",
- );
- if (process) {
- console.log(`[DPL] Process bitrate:`, {
- key: process.maxBitrate.key,
- value: process.maxBitrate.value,
- runTimeTicks: process.item.RunTimeTicks,
- });
- if (process.maxBitrate.value && process.item.RunTimeTicks) {
- const { estimateDownloadSize } = require("@/utils/download");
- estimatedTotalBytes = estimateDownloadSize(
- process.maxBitrate.value,
- process.item.RunTimeTicks,
- );
- console.log(
- `[DPL] Calculated estimatedTotalBytes:`,
- estimatedTotalBytes,
- );
- } else {
- console.log(
- `[DPL] Cannot estimate size - bitrate.value or RunTimeTicks missing`,
- );
- }
+ if (process?.maxBitrate.value && process.item.RunTimeTicks) {
+ const { estimateDownloadSize } = require("@/utils/download");
+ estimatedTotalBytes = estimateDownloadSize(
+ process.maxBitrate.value,
+ process.item.RunTimeTicks,
+ );
}
}
diff --git a/providers/WebSocketProvider.tsx b/providers/WebSocketProvider.tsx
index ed9db754..c704d373 100644
--- a/providers/WebSocketProvider.tsx
+++ b/providers/WebSocketProvider.tsx
@@ -1,4 +1,5 @@
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
+import { router } from "expo-router";
import { useAtomValue } from "jotai";
import {
createContext,
@@ -11,7 +12,6 @@ import {
useState,
} from "react";
import { AppState, type AppStateStatus } from "react-native";
-import useRouter from "@/hooks/useAppRouter";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
@@ -28,6 +28,20 @@ const LIBRARY_CHANGE_QUERY_KEYS = [
["episodes"],
] as const;
+// Query keys that depend on per-user playback state (resume position, played
+// status, favorites) and should be refreshed when the server reports a
+// `UserDataChanged`. Scoped to the progression-based sections so finishing an
+// episode does not pointlessly refetch "recently added" or suggestions.
+const USER_DATA_CHANGE_QUERY_KEYS = [
+ ["home", "continueAndNextUp"],
+ ["home", "resumeItems"],
+ ["home", "nextUp-all"],
+ ["home", "heroItems"],
+ ["resumeItems"],
+ ["nextUp-all"],
+ ["nextUp"],
+] as const;
+
interface WebSocketMessage {
MessageType: string;
Data: any;
@@ -38,10 +52,30 @@ interface WebSocketProviderProps {
children: ReactNode;
}
+/**
+ * Handler invoked for every message of a given `MessageType`. Receives the
+ * message `Data` payload and the full message.
+ */
+type WebSocketMessageHandler = (data: any, message: WebSocketMessage) => void;
+
interface WebSocketContextType {
ws: WebSocket | null;
isConnected: boolean;
+ /**
+ * @deprecated Prefer `subscribe`. `lastMessage` only keeps the most recent
+ * message, so bursts arriving in the same tick are coalesced and lost. Kept
+ * for `useWebsockets` (GeneralCommand handling) until it is migrated.
+ */
lastMessage: WebSocketMessage | null;
+ /**
+ * Subscribe to a given message type. The handler is called synchronously for
+ * every matching message (no coalescing, unlike `lastMessage`). Returns an
+ * unsubscribe function to call on cleanup.
+ */
+ subscribe: (
+ messageType: string,
+ handler: WebSocketMessageHandler,
+ ) => () => void;
sendMessage: (message: any) => void;
clearLastMessage: () => void;
}
@@ -54,7 +88,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const [ws, setWs] = useState(null);
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState(null);
- const router = useRouter();
const queryClient = useNetworkAwareQueryClient();
const deviceId = useMemo(() => {
return getOrSetDeviceId();
@@ -63,8 +96,76 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const libraryChangeDebounceRef = useRef | null>(
null,
);
+ const userDataChangeDebounceRef = useRef | null>(null);
+ // Handle for the onerror backoff timer. Tracked so a reconnect triggered by
+ // another path (foreground, network reconnect, effect re-run) can cancel a
+ // pending one โ an untracked timer would later open a second socket.
+ const reconnectTimeoutRef = useRef | null>(
+ null,
+ );
+
+ // Pub/sub registry: messageType -> set of handlers. Stored in a ref so
+ // subscribing/dispatching never triggers a re-render.
+ const listenersRef = useRef