mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-11 16:30:24 +01:00
Compare commits
11 Commits
cleanup/de
...
feat/kefin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c024d1ed05 | ||
|
|
96116e0451 | ||
|
|
938918fa06 | ||
|
|
a4b6f456f2 | ||
|
|
c648134954 | ||
|
|
97eec2438b | ||
|
|
1d0c2f0a31 | ||
|
|
0a2dadffd2 | ||
|
|
6818ea380f | ||
|
|
7cf0a13317 | ||
|
|
eba72e9d73 |
54
.github/renovate.json
vendored
54
.github/renovate.json
vendored
@@ -44,22 +44,42 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lockFileMaintenance": {
|
"vulnerabilityAlerts": {
|
||||||
"vulnerabilityAlerts": {
|
"enabled": true,
|
||||||
"enabled": true,
|
"addLabels": ["security", "vulnerability"],
|
||||||
"addLabels": ["security", "vulnerability"],
|
"assigneesFromCodeOwners": true,
|
||||||
"assigneesFromCodeOwners": true,
|
"commitMessageSuffix": " [SECURITY]"
|
||||||
"commitMessageSuffix": " [SECURITY]"
|
},
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"description": "Expo SDK coherence: expo, react, react-native and Expo-managed modules are pinned by the Expo SDK and must move together (via `expo install --fix`), so do not raise individual update PRs — group them and require manual approval from the Dependency Dashboard",
|
||||||
|
"matchPackageNames": [
|
||||||
|
"expo",
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react-native",
|
||||||
|
"react-native-web",
|
||||||
|
"expo-*",
|
||||||
|
"@expo/*"
|
||||||
|
],
|
||||||
|
"groupName": "Expo SDK",
|
||||||
|
"dependencyDashboardApproval": true
|
||||||
},
|
},
|
||||||
"packageRules": [
|
{
|
||||||
{
|
"description": "Group minor and patch GitHub Action updates into a single PR",
|
||||||
"description": "Group minor and patch GitHub Action updates into a single PR",
|
"matchManagers": ["github-actions"],
|
||||||
"matchManagers": ["github-actions"],
|
"groupName": "CI dependencies",
|
||||||
"groupName": "CI dependencies",
|
"groupSlug": "ci-deps",
|
||||||
"groupSlug": "ci-deps",
|
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
||||||
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
"automerge": true
|
||||||
"automerge": true
|
},
|
||||||
}
|
{
|
||||||
]
|
"description": "androidx and other Google-hosted Maven packages resolve from Google's Maven repository (not Maven Central)",
|
||||||
}
|
"matchDatasources": ["maven"],
|
||||||
|
"registryUrls": [
|
||||||
|
"https://dl.google.com/dl/android/maven2/",
|
||||||
|
"https://repo.maven.apache.org/maven2/"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
15
.github/workflows/build-apps.yml
vendored
15
.github/workflows/build-apps.yml
vendored
@@ -11,6 +11,15 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [develop, master]
|
branches: [develop, master]
|
||||||
|
|
||||||
|
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
|
||||||
|
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
|
||||||
|
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
|
||||||
|
# run (artifacts + logs) without needing Expo access.
|
||||||
|
env:
|
||||||
|
EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||||
|
EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
|
||||||
|
EXPO_PUBLIC_GIT_RUN_NUMBER: ${{ github.run_number }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-android-phone:
|
build-android-phone:
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||||
@@ -231,7 +240,9 @@ jobs:
|
|||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 0
|
EXPO_TV: 0
|
||||||
run: eas build -p ios --local --non-interactive
|
# `ci` profile (extends production, autoIncrement off): keeps CI builds out of
|
||||||
|
# the production version tier and stops them inflating the store build counter.
|
||||||
|
run: eas build -p ios --local --non-interactive --profile ci
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
- name: 📅 Set date tag
|
||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
@@ -356,7 +367,7 @@ jobs:
|
|||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
EXPO_TV: 1
|
EXPO_TV: 1
|
||||||
run: eas build -p ios --local --non-interactive
|
run: eas build -p ios --local --non-interactive --profile ci_tv
|
||||||
|
|
||||||
- name: 📅 Set date tag
|
- name: 📅 Set date tag
|
||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|||||||
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@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
||||||
with:
|
with:
|
||||||
dirtyLabel: '⚔️ merge-conflict'
|
dirtyLabel: '⚔️ merge-conflict'
|
||||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ interface ModalOptions {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
|
||||||
|
- Simple content modal
|
||||||
|
- Modal with custom snap points
|
||||||
|
- Complex component in modal
|
||||||
|
- Success/error modals triggered from functions
|
||||||
|
|
||||||
## Default Styling
|
## Default Styling
|
||||||
|
|
||||||
The modal uses these default styles (can be overridden via options):
|
The modal uses these default styles (can be overridden via options):
|
||||||
|
|||||||
@@ -1,3 +1,47 @@
|
|||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
|
||||||
|
// Build metadata, injected into `extra.build` and read at runtime via
|
||||||
|
// expo-constants (see utils/version.ts). Sources in priority order:
|
||||||
|
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
||||||
|
const git = (args) => {
|
||||||
|
try {
|
||||||
|
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMeta = {
|
||||||
|
commit:
|
||||||
|
(
|
||||||
|
process.env.EAS_BUILD_GIT_COMMIT_HASH ||
|
||||||
|
process.env.GITHUB_SHA ||
|
||||||
|
process.env.EXPO_PUBLIC_GIT_COMMIT ||
|
||||||
|
git(["rev-parse", "HEAD"]) ||
|
||||||
|
""
|
||||||
|
).slice(0, 7) || null,
|
||||||
|
branch:
|
||||||
|
process.env.EAS_BUILD_GIT_BRANCH ||
|
||||||
|
process.env.GITHUB_HEAD_REF ||
|
||||||
|
process.env.GITHUB_REF_NAME ||
|
||||||
|
process.env.EXPO_PUBLIC_GIT_BRANCH ||
|
||||||
|
git(["rev-parse", "--abbrev-ref", "HEAD"]) ||
|
||||||
|
null,
|
||||||
|
profile:
|
||||||
|
process.env.EAS_BUILD_PROFILE ||
|
||||||
|
process.env.EXPO_PUBLIC_BUILD_PROFILE ||
|
||||||
|
null,
|
||||||
|
// GitHub Actions run number (#2098) — lets anyone map a sideloaded CI build back
|
||||||
|
// to its Actions run (artifacts + logs) without Expo access. Null outside CI.
|
||||||
|
runNumber:
|
||||||
|
process.env.GITHUB_RUN_NUMBER ||
|
||||||
|
process.env.EXPO_PUBLIC_GIT_RUN_NUMBER ||
|
||||||
|
null,
|
||||||
|
builtAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = ({ config }) => {
|
module.exports = ({ config }) => {
|
||||||
if (process.env.EXPO_TV !== "1") {
|
if (process.env.EXPO_TV !== "1") {
|
||||||
config.plugins.push("expo-background-task");
|
config.plugins.push("expo-background-task");
|
||||||
@@ -22,6 +66,8 @@ module.exports = ({ config }) => {
|
|||||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config.extra = { ...config.extra, build: buildMeta };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||||
...config,
|
...config,
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { FavoritesTabButtons } from "@/components/favorites/FavoritesTabButtons";
|
||||||
import { Favorites } from "@/components/home/Favorites";
|
import { Favorites } from "@/components/home/Favorites";
|
||||||
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function FavoritesPage() {
|
export default function FavoritesPage() {
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
// KefinTweaks watchlist (Likes-backed) view, toggled in-place like Discover.
|
||||||
|
const watchlistEnabled = settings?.useKefinTweaks ?? false;
|
||||||
|
const [viewType, setViewType] = useState<"Favorites" | "Watchlist">(
|
||||||
|
"Favorites",
|
||||||
|
);
|
||||||
const refetch = useCallback(async () => {
|
const refetch = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await invalidateCache();
|
await invalidateCache();
|
||||||
@@ -20,6 +30,8 @@ export default function FavoritesPage() {
|
|||||||
return <TVFavorites />;
|
return <TVFavorites />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isWatchlist = watchlistEnabled && viewType === "Watchlist";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
@@ -34,7 +46,26 @@ export default function FavoritesPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
||||||
<Favorites />
|
{watchlistEnabled && (
|
||||||
|
<View className='pl-4 pr-4 flex flex-row mb-2'>
|
||||||
|
<FavoritesTabButtons
|
||||||
|
viewType={viewType}
|
||||||
|
setViewType={setViewType}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{isWatchlist ? (
|
||||||
|
<Favorites
|
||||||
|
filter='Likes'
|
||||||
|
queryKeyBase='watchlist'
|
||||||
|
seeAllNamespace='kefintweaksWatchlist'
|
||||||
|
emptyTitleKey='kefintweaksWatchlist.noDataTitle'
|
||||||
|
emptyTextKey='kefintweaksWatchlist.noData'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Favorites />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Api } from "@jellyfin/sdk";
|
|||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
|
ItemFilter,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
@@ -10,7 +11,7 @@ import { Stack, useLocalSearchParams } from "expo-router";
|
|||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useWindowDimensions, View } from "react-native";
|
import { Platform, useWindowDimensions, 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 { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
@@ -52,9 +53,13 @@ export default function FavoritesSeeAllScreen() {
|
|||||||
const searchParams = useLocalSearchParams<{
|
const searchParams = useLocalSearchParams<{
|
||||||
type?: string;
|
type?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
filter?: string;
|
||||||
}>();
|
}>();
|
||||||
const typeParam = searchParams.type;
|
const typeParam = searchParams.type;
|
||||||
const titleParam = searchParams.title;
|
const titleParam = searchParams.title;
|
||||||
|
// Watchlist (KefinTweaks) reuses this screen with the "Likes" filter.
|
||||||
|
const filter: ItemFilter =
|
||||||
|
searchParams.filter === "Likes" ? "Likes" : "IsFavorite";
|
||||||
|
|
||||||
const itemType = useMemo(() => {
|
const itemType = useMemo(() => {
|
||||||
if (!isFavoriteType(typeParam)) return null;
|
if (!isFavoriteType(typeParam)) return null;
|
||||||
@@ -77,7 +82,7 @@ export default function FavoritesSeeAllScreen() {
|
|||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
sortBy: ["SeriesSortName", "SortName"],
|
sortBy: ["SeriesSortName", "SortName"],
|
||||||
sortOrder: ["Ascending"],
|
sortOrder: ["Ascending"],
|
||||||
filters: ["IsFavorite"],
|
filters: [filter],
|
||||||
recursive: true,
|
recursive: true,
|
||||||
fields: ["PrimaryImageAspectRatio"],
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
collapseBoxSetItems: false,
|
collapseBoxSetItems: false,
|
||||||
@@ -90,12 +95,12 @@ export default function FavoritesSeeAllScreen() {
|
|||||||
|
|
||||||
return response.data.Items || [];
|
return response.data.Items || [];
|
||||||
},
|
},
|
||||||
[api, itemType, user?.Id],
|
[api, itemType, user?.Id, filter],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||||
useInfiniteQuery({
|
useInfiniteQuery({
|
||||||
queryKey: ["favorites", "see-all", itemType],
|
queryKey: ["favorites", "see-all", itemType, filter],
|
||||||
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
|
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
|
||||||
getNextPageParam: (lastPage, pages) => {
|
getNextPageParam: (lastPage, pages) => {
|
||||||
if (!lastPage || lastPage.length < pageSize) return undefined;
|
if (!lastPage || lastPage.length < pageSize) return undefined;
|
||||||
@@ -155,7 +160,7 @@ export default function FavoritesSeeAllScreen() {
|
|||||||
options={{
|
options={{
|
||||||
headerTitle: headerTitle,
|
headerTitle: headerTitle,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: true,
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useEffect, useMemo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { AddToFavorites } from "@/components/AddToFavorites";
|
import { AddToFavorites } from "@/components/AddToFavorites";
|
||||||
|
import { AddToKefinWatchlist } from "@/components/AddToKefinWatchlist";
|
||||||
import { DownloadItems } from "@/components/DownloadItem";
|
import { DownloadItems } from "@/components/DownloadItem";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { NextUp } from "@/components/series/NextUp";
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
@@ -18,6 +19,7 @@ import { TVSeriesPage } from "@/components/series/TVSeriesPage";
|
|||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
buildOfflineSeriesFromEpisodes,
|
buildOfflineSeriesFromEpisodes,
|
||||||
getDownloadedEpisodesForSeries,
|
getDownloadedEpisodesForSeries,
|
||||||
@@ -30,6 +32,7 @@ import { storage } from "@/utils/mmkv";
|
|||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const {
|
const {
|
||||||
id: seriesId,
|
id: seriesId,
|
||||||
@@ -137,6 +140,7 @@ const page: React.FC = () => {
|
|||||||
!isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
|
!isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
|
{settings?.useKefinTweaks && <AddToKefinWatchlist item={item} />}
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<DownloadItems
|
<DownloadItems
|
||||||
size='large'
|
size='large'
|
||||||
@@ -157,7 +161,7 @@ const page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
) : null,
|
) : null,
|
||||||
});
|
});
|
||||||
}, [allEpisodes, isLoading, item, isOffline]);
|
}, [allEpisodes, isLoading, item, isOffline, settings?.useKefinTweaks]);
|
||||||
|
|
||||||
// For offline mode, we can show the page even without backdropUrl
|
// For offline mode, we can show the page even without backdropUrl
|
||||||
if (!item || (!isOffline && !backdropUrl)) return null;
|
if (!item || (!isOffline && !backdropUrl)) return null;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./api";
|
export * from "./api";
|
||||||
export * from "./mmkv";
|
export * from "./mmkv";
|
||||||
export * from "./number";
|
export * from "./number";
|
||||||
|
export * from "./string";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ declare global {
|
|||||||
bytesToReadable(decimals?: number): string;
|
bytesToReadable(decimals?: number): string;
|
||||||
secondsToMilliseconds(): number;
|
secondsToMilliseconds(): number;
|
||||||
minutesToMilliseconds(): number;
|
minutesToMilliseconds(): number;
|
||||||
|
hoursToMilliseconds(): number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,4 +28,8 @@ Number.prototype.minutesToMilliseconds = function () {
|
|||||||
return this.valueOf() * (60).secondsToMilliseconds();
|
return this.valueOf() * (60).secondsToMilliseconds();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Number.prototype.hoursToMilliseconds = function () {
|
||||||
|
return this.valueOf() * (60).minutesToMilliseconds();
|
||||||
|
};
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
14
augmentations/string.ts
Normal file
14
augmentations/string.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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 {};
|
||||||
28
components/AddToKefinWatchlist.tsx
Normal file
28
components/AddToKefinWatchlist.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
|
import { useWatchlist } from "@/hooks/useWatchlist";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KefinTweaks watchlist toggle, backed by Jellyfin's "Likes" rating.
|
||||||
|
* Render only when settings.useKefinTweaks is enabled.
|
||||||
|
*/
|
||||||
|
export const AddToKefinWatchlist: FC<Props> = ({ item, ...props }) => {
|
||||||
|
const { isWatchlisted, toggleWatchlist } = useWatchlist(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<RoundButton
|
||||||
|
size='large'
|
||||||
|
icon={isWatchlisted ? "bookmark" : "bookmark-outline"}
|
||||||
|
color={isWatchlisted ? "purple" : "white"}
|
||||||
|
onPress={toggleWatchlist}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
0
components/ContextMenu.tv.ts
Normal file
0
components/ContextMenu.tv.ts
Normal file
203
components/ExampleGlobalModalUsage.tsx
Normal file
203
components/ExampleGlobalModalUsage.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Example Usage of Global Modal
|
||||||
|
*
|
||||||
|
* This file demonstrates how to use the global modal system from anywhere in your app.
|
||||||
|
* You can delete this file after understanding how it works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 1: Simple Content Modal
|
||||||
|
*/
|
||||||
|
export const SimpleModalExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
|
||||||
|
<Text className='text-white mb-4'>
|
||||||
|
This is a simple modal with just some text content.
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400'>
|
||||||
|
Swipe down or tap outside to close.
|
||||||
|
</Text>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-purple-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Open Simple Modal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 2: Modal with Custom Snap Points
|
||||||
|
*/
|
||||||
|
export const CustomSnapPointsExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6' style={{ minHeight: 400 }}>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||||
|
Custom Snap Points
|
||||||
|
</Text>
|
||||||
|
<Text className='text-white mb-4'>
|
||||||
|
This modal has custom snap points (25%, 50%, 90%).
|
||||||
|
</Text>
|
||||||
|
<View className='bg-neutral-800 p-4 rounded-lg'>
|
||||||
|
<Text className='text-white'>
|
||||||
|
Try dragging the modal to different heights!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>,
|
||||||
|
{
|
||||||
|
snapPoints: ["25%", "50%", "90%"],
|
||||||
|
enableDynamicSizing: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-blue-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Custom Snap Points</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 3: Complex Component in Modal
|
||||||
|
*/
|
||||||
|
const SettingsModalContent = () => {
|
||||||
|
const { hideModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const settings = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "Notifications",
|
||||||
|
icon: "notifications-outline" as const,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "Auto-play",
|
||||||
|
icon: "play-outline" as const,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
|
||||||
|
|
||||||
|
{settings.map((setting, index) => (
|
||||||
|
<View
|
||||||
|
key={setting.id}
|
||||||
|
className={`flex-row items-center justify-between py-4 ${
|
||||||
|
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center gap-3'>
|
||||||
|
<Ionicons name={setting.icon} size={24} color='white' />
|
||||||
|
<Text className='text-white text-lg'>{setting.title}</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`w-12 h-7 rounded-full ${
|
||||||
|
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
|
||||||
|
setting.enabled ? "translate-x-6" : "translate-x-1"
|
||||||
|
}`}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={hideModal}
|
||||||
|
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold text-center'>Close</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComplexModalExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(<SettingsModalContent />);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-green-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Complex Component</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 4: Modal Triggered from Function (e.g., API response)
|
||||||
|
*/
|
||||||
|
export const useShowSuccessModal = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
return (message: string) => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6 items-center'>
|
||||||
|
<View className='bg-green-500 rounded-full p-4 mb-4'>
|
||||||
|
<Ionicons name='checkmark' size={48} color='white' />
|
||||||
|
</View>
|
||||||
|
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
|
||||||
|
<Text className='text-white text-center'>{message}</Text>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Demo Component
|
||||||
|
*/
|
||||||
|
export const GlobalModalDemo = () => {
|
||||||
|
const showSuccess = useShowSuccessModal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-6 gap-4'>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||||
|
Global Modal Examples
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<SimpleModalExample />
|
||||||
|
<CustomSnapPointsExample />
|
||||||
|
<ComplexModalExample />
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => showSuccess("Operation completed successfully!")}
|
||||||
|
className='bg-orange-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Show Success Modal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -29,6 +29,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { AddToFavorites } from "./AddToFavorites";
|
import { AddToFavorites } from "./AddToFavorites";
|
||||||
|
import { AddToKefinWatchlist } from "./AddToKefinWatchlist";
|
||||||
import { AddToWatchlist } from "./AddToWatchlist";
|
import { AddToWatchlist } from "./AddToWatchlist";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
@@ -138,6 +139,9 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
|||||||
|
|
||||||
<PlayedStatus items={[item]} size='large' />
|
<PlayedStatus items={[item]} size='large' />
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
|
{settings.useKefinTweaks && (
|
||||||
|
<AddToKefinWatchlist item={item} />
|
||||||
|
)}
|
||||||
{settings.streamyStatsServerUrl &&
|
{settings.streamyStatsServerUrl &&
|
||||||
!settings.hideWatchlistsTab && (
|
!settings.hideWatchlistsTab && (
|
||||||
<AddToWatchlist item={item} />
|
<AddToWatchlist item={item} />
|
||||||
@@ -160,6 +164,9 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
|||||||
|
|
||||||
<PlayedStatus items={[item]} size='large' />
|
<PlayedStatus items={[item]} size='large' />
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
|
{settings.useKefinTweaks && (
|
||||||
|
<AddToKefinWatchlist item={item} />
|
||||||
|
)}
|
||||||
{settings.streamyStatsServerUrl &&
|
{settings.streamyStatsServerUrl &&
|
||||||
!settings.hideWatchlistsTab && (
|
!settings.hideWatchlistsTab && (
|
||||||
<AddToWatchlist item={item} />
|
<AddToWatchlist item={item} />
|
||||||
@@ -178,6 +185,7 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
|||||||
settings.hideRemoteSessionButton,
|
settings.hideRemoteSessionButton,
|
||||||
settings.streamyStatsServerUrl,
|
settings.streamyStatsServerUrl,
|
||||||
settings.hideWatchlistsTab,
|
settings.hideWatchlistsTab,
|
||||||
|
settings.useKefinTweaks,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
TVRefreshButton,
|
TVRefreshButton,
|
||||||
TVSeriesNavigation,
|
TVSeriesNavigation,
|
||||||
TVTechnicalDetails,
|
TVTechnicalDetails,
|
||||||
|
TVWatchlistButton,
|
||||||
} from "@/components/tv";
|
} from "@/components/tv";
|
||||||
import type { Track } from "@/components/video-player/controls/types";
|
import type { Track } from "@/components/video-player/controls/types";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
@@ -752,6 +753,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
</Text>
|
</Text>
|
||||||
</TVButton>
|
</TVButton>
|
||||||
<TVFavoriteButton item={item} />
|
<TVFavoriteButton item={item} />
|
||||||
|
{settings.useKefinTweaks && <TVWatchlistButton item={item} />}
|
||||||
<TVPlayedButton item={item} />
|
<TVPlayedButton item={item} />
|
||||||
<TVRefreshButton itemId={item.Id} />
|
<TVRefreshButton itemId={item.Id} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -69,17 +69,23 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
|
|||||||
[isAndroid],
|
[isAndroid],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isPresentedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
bottomSheetModalRef.current?.present();
|
bottomSheetModalRef.current?.present();
|
||||||
} else {
|
} else if (isPresentedRef.current) {
|
||||||
bottomSheetModalRef.current?.dismiss();
|
bottomSheetModalRef.current?.dismiss();
|
||||||
|
isPresentedRef.current = false;
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback(
|
const handleSheetChanges = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (index === -1) {
|
if (index >= 0) {
|
||||||
|
isPresentedRef.current = true;
|
||||||
|
} else if (index === -1 && isPresentedRef.current) {
|
||||||
|
isPresentedRef.current = false;
|
||||||
resetState();
|
resetState();
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|||||||
20
components/common/LargePoster.tsx
Normal file
20
components/common/LargePoster.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Image } from "expo-image";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
|
||||||
|
if (!url)
|
||||||
|
return (
|
||||||
|
<View className='p-4 rounded-xl overflow-hidden '>
|
||||||
|
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-4 rounded-xl overflow-hidden '>
|
||||||
|
<Image
|
||||||
|
source={{ uri: url }}
|
||||||
|
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useFavorite } from "@/hooks/useFavorite";
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
|
import { useWatchlist } from "@/hooks/useWatchlist";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -155,6 +157,8 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||||
|
const { isWatchlisted, toggleWatchlist } = useWatchlist(item);
|
||||||
|
const { settings } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
@@ -183,36 +187,66 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const options: string[] = [
|
// Build options as { label, action } so dynamic entries (watchlist,
|
||||||
t("common.mark_as_played"),
|
// offline delete) don't break index-based handling.
|
||||||
t("common.mark_as_not_played"),
|
const actions: {
|
||||||
isFavorite
|
label: string;
|
||||||
? t("music.track_options.remove_from_favorites")
|
action: () => void;
|
||||||
: t("music.track_options.add_to_favorites"),
|
destructive?: boolean;
|
||||||
...(isOffline ? [t("home.downloads.delete_download")] : []),
|
}[] = [
|
||||||
t("common.cancel"),
|
{
|
||||||
|
label: t("common.mark_as_played"),
|
||||||
|
action: () => {
|
||||||
|
markAsPlayedStatus(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("common.mark_as_not_played"),
|
||||||
|
action: () => {
|
||||||
|
markAsPlayedStatus(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: isFavorite
|
||||||
|
? t("music.track_options.remove_from_favorites")
|
||||||
|
: t("music.track_options.add_to_favorites"),
|
||||||
|
action: toggleFavorite,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (settings?.useKefinTweaks) {
|
||||||
|
actions.push({
|
||||||
|
label: isWatchlisted
|
||||||
|
? t("watchlists.remove_from_watchlist")
|
||||||
|
: t("watchlists.add_to_watchlist"),
|
||||||
|
action: toggleWatchlist,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOffline && item.Id) {
|
||||||
|
const id = item.Id;
|
||||||
|
actions.push({
|
||||||
|
label: t("home.downloads.delete_download"),
|
||||||
|
action: () => deleteFile(id),
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = [...actions.map((a) => a.label), t("common.cancel")];
|
||||||
const cancelButtonIndex = options.length - 1;
|
const cancelButtonIndex = options.length - 1;
|
||||||
const destructiveButtonIndex = isOffline
|
const destructiveButtonIndex = actions.findIndex((a) => a.destructive);
|
||||||
? cancelButtonIndex - 1
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
showActionSheetWithOptions(
|
||||||
{
|
{
|
||||||
options,
|
options,
|
||||||
cancelButtonIndex,
|
cancelButtonIndex,
|
||||||
destructiveButtonIndex,
|
destructiveButtonIndex:
|
||||||
|
destructiveButtonIndex === -1 ? undefined : destructiveButtonIndex,
|
||||||
},
|
},
|
||||||
async (selectedIndex) => {
|
(selectedIndex) => {
|
||||||
if (selectedIndex === 0) {
|
if (selectedIndex === undefined || selectedIndex >= actions.length)
|
||||||
await markAsPlayedStatus(true);
|
return;
|
||||||
} else if (selectedIndex === 1) {
|
actions[selectedIndex].action();
|
||||||
await markAsPlayedStatus(false);
|
|
||||||
} else if (selectedIndex === 2) {
|
|
||||||
toggleFavorite();
|
|
||||||
} else if (isOffline && selectedIndex === 3 && item.Id) {
|
|
||||||
deleteFile(item.Id);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
@@ -220,6 +254,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
isFavorite,
|
isFavorite,
|
||||||
markAsPlayedStatus,
|
markAsPlayedStatus,
|
||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
|
isWatchlisted,
|
||||||
|
toggleWatchlist,
|
||||||
|
settings?.useKefinTweaks,
|
||||||
isOffline,
|
isOffline,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
item.Id,
|
item.Id,
|
||||||
|
|||||||
28
components/common/VerticalSkeleton.tsx
Normal file
28
components/common/VerticalSkeleton.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
width: "32%",
|
||||||
|
}}
|
||||||
|
className='flex flex-col'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
}}
|
||||||
|
className='w-full bg-neutral-800 mb-2 rounded-lg'
|
||||||
|
/>
|
||||||
|
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
||||||
|
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
||||||
|
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
74
components/favorites/FavoritesTabButtons.tsx
Normal file
74
components/favorites/FavoritesTabButtons.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Tag } from "@/components/GenreTags";
|
||||||
|
|
||||||
|
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
|
||||||
|
// A static top-level import crashes the route tree on tvOS at module load.
|
||||||
|
// Load it lazily and only off-TV; TV never renders this component.
|
||||||
|
const { Button, Host, HStack, Spacer } = Platform.isTV
|
||||||
|
? ({} as typeof import("@expo/ui/swift-ui"))
|
||||||
|
: require("@expo/ui/swift-ui");
|
||||||
|
const { buttonStyle } = Platform.isTV
|
||||||
|
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
|
||||||
|
: require("@expo/ui/swift-ui/modifiers");
|
||||||
|
|
||||||
|
type ViewType = "Favorites" | "Watchlist";
|
||||||
|
|
||||||
|
interface FavoritesTabButtonsProps {
|
||||||
|
viewType: ViewType;
|
||||||
|
setViewType: (type: ViewType) => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FavoritesTabButtons: React.FC<FavoritesTabButtonsProps> = ({
|
||||||
|
viewType,
|
||||||
|
setViewType,
|
||||||
|
t,
|
||||||
|
}) => {
|
||||||
|
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<Host style={{ height: 40, flex: 1 }}>
|
||||||
|
<HStack spacing={8}>
|
||||||
|
<Button
|
||||||
|
modifiers={[
|
||||||
|
buttonStyle(
|
||||||
|
viewType === "Favorites" ? "glassProminent" : "glass",
|
||||||
|
),
|
||||||
|
]}
|
||||||
|
onPress={() => setViewType("Favorites")}
|
||||||
|
label={t("tabs.favorites")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
modifiers={[
|
||||||
|
buttonStyle(
|
||||||
|
viewType === "Watchlist" ? "glassProminent" : "glass",
|
||||||
|
),
|
||||||
|
]}
|
||||||
|
onPress={() => setViewType("Watchlist")}
|
||||||
|
label={t("favorites.watchlist")}
|
||||||
|
/>
|
||||||
|
<Spacer />
|
||||||
|
</HStack>
|
||||||
|
</Host>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android UI
|
||||||
|
return (
|
||||||
|
<View className='flex flex-row gap-1 mr-1'>
|
||||||
|
<TouchableOpacity onPress={() => setViewType("Favorites")}>
|
||||||
|
<Tag
|
||||||
|
text={t("tabs.favorites")}
|
||||||
|
textClass='p-1'
|
||||||
|
className={viewType === "Favorites" ? "bg-purple-600" : undefined}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => setViewType("Watchlist")}>
|
||||||
|
<Tag
|
||||||
|
text={t("favorites.watchlist")}
|
||||||
|
textClass='p-1'
|
||||||
|
className={viewType === "Watchlist" ? "bg-purple-600" : undefined}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
117
components/favorites/TVFavoritesTabBadges.tsx
Normal file
117
components/favorites/TVFavoritesTabBadges.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Animated, Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
|
type ViewType = "Favorites" | "Watchlist";
|
||||||
|
|
||||||
|
interface TVFavoritesTabBadgeProps {
|
||||||
|
label: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVFavoritesTabBadge: React.FC<TVFavoritesTabBadgeProps> = ({
|
||||||
|
label,
|
||||||
|
isSelected,
|
||||||
|
onPress,
|
||||||
|
hasTVPreferredFocus = false,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ duration: 150 });
|
||||||
|
|
||||||
|
// Design language: white for focused/selected, transparent white for unfocused
|
||||||
|
const getBackgroundColor = () => {
|
||||||
|
if (focused) return "#fff";
|
||||||
|
if (isSelected) return "rgba(255,255,255,0.25)";
|
||||||
|
return "rgba(255,255,255,0.1)";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTextColor = () => {
|
||||||
|
if (focused) return "#000";
|
||||||
|
return "#fff";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: getBackgroundColor(),
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.4 : 0,
|
||||||
|
shadowRadius: focused ? 12 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: getTextColor(),
|
||||||
|
fontWeight: isSelected || focused ? "600" : "400",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TVFavoritesTabBadgesProps {
|
||||||
|
viewType: ViewType;
|
||||||
|
setViewType: (type: ViewType) => void;
|
||||||
|
/** Only render the toggle when the KefinTweaks watchlist is enabled. */
|
||||||
|
enabled: boolean;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVFavoritesTabBadges: React.FC<TVFavoritesTabBadgesProps> = ({
|
||||||
|
viewType,
|
||||||
|
setViewType,
|
||||||
|
enabled,
|
||||||
|
hasTVPreferredFocus = false,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVFavoritesTabBadge
|
||||||
|
label={t("tabs.favorites")}
|
||||||
|
isSelected={viewType === "Favorites"}
|
||||||
|
onPress={() => setViewType("Favorites")}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && viewType === "Favorites"}
|
||||||
|
/>
|
||||||
|
<TVFavoritesTabBadge
|
||||||
|
label={t("favorites.watchlist")}
|
||||||
|
isSelected={viewType === "Watchlist"}
|
||||||
|
onPress={() => setViewType("Watchlist")}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && viewType === "Watchlist"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
import type { Api } from "@jellyfin/sdk";
|
||||||
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
import type {
|
||||||
|
BaseItemKind,
|
||||||
|
ItemFilter,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
@@ -22,7 +25,24 @@ type FavoriteTypes =
|
|||||||
| "Playlist";
|
| "Playlist";
|
||||||
type EmptyState = Record<FavoriteTypes, boolean>;
|
type EmptyState = Record<FavoriteTypes, boolean>;
|
||||||
|
|
||||||
export const Favorites = () => {
|
interface FavoritesProps {
|
||||||
|
/** Jellyfin item filter. "IsFavorite" (default) or "Likes" for the watchlist view. */
|
||||||
|
filter?: ItemFilter;
|
||||||
|
/** Query key segment used to keep favorites/watchlist caches separate. */
|
||||||
|
queryKeyBase?: string;
|
||||||
|
emptyTitleKey?: string;
|
||||||
|
emptyTextKey?: string;
|
||||||
|
/** Namespace for the see-all page headers ("favorites" or "kefintweaksWatchlist"). */
|
||||||
|
seeAllNamespace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Favorites = ({
|
||||||
|
filter = "IsFavorite",
|
||||||
|
queryKeyBase = "favorites",
|
||||||
|
emptyTitleKey = "favorites.noDataTitle",
|
||||||
|
emptyTextKey = "favorites.noData",
|
||||||
|
seeAllNamespace = "favorites",
|
||||||
|
}: FavoritesProps = {}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -46,7 +66,7 @@ export const Favorites = () => {
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
sortBy: ["SeriesSortName", "SortName"],
|
sortBy: ["SeriesSortName", "SortName"],
|
||||||
sortOrder: ["Ascending"],
|
sortOrder: ["Ascending"],
|
||||||
filters: ["IsFavorite"],
|
filters: [filter],
|
||||||
recursive: true,
|
recursive: true,
|
||||||
fields: ["PrimaryImageAspectRatio"],
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
collapseBoxSetItems: false,
|
collapseBoxSetItems: false,
|
||||||
@@ -68,7 +88,7 @@ export const Favorites = () => {
|
|||||||
|
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
[api, user],
|
[api, user, filter],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset empty state when component mounts or dependencies change
|
// Reset empty state when component mounts or dependencies change
|
||||||
@@ -126,44 +146,68 @@ export const Favorites = () => {
|
|||||||
const handleSeeAllSeries = useCallback(() => {
|
const handleSeeAllSeries = useCallback(() => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||||
params: { type: "Series", title: t("favorites.series") },
|
params: {
|
||||||
|
type: "Series",
|
||||||
|
title: t(`${seeAllNamespace}.seeAllSeries`),
|
||||||
|
filter,
|
||||||
|
},
|
||||||
} as any);
|
} as any);
|
||||||
}, [router]);
|
}, [router, filter, seeAllNamespace]);
|
||||||
|
|
||||||
const handleSeeAllMovies = useCallback(() => {
|
const handleSeeAllMovies = useCallback(() => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||||
params: { type: "Movie", title: t("favorites.movies") },
|
params: {
|
||||||
|
type: "Movie",
|
||||||
|
title: t(`${seeAllNamespace}.seeAllMovies`),
|
||||||
|
filter,
|
||||||
|
},
|
||||||
} as any);
|
} as any);
|
||||||
}, [router]);
|
}, [router, filter, seeAllNamespace]);
|
||||||
|
|
||||||
const handleSeeAllEpisodes = useCallback(() => {
|
const handleSeeAllEpisodes = useCallback(() => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||||
params: { type: "Episode", title: t("favorites.episodes") },
|
params: {
|
||||||
|
type: "Episode",
|
||||||
|
title: t(`${seeAllNamespace}.seeAllEpisodes`),
|
||||||
|
filter,
|
||||||
|
},
|
||||||
} as any);
|
} as any);
|
||||||
}, [router]);
|
}, [router, filter, seeAllNamespace]);
|
||||||
|
|
||||||
const handleSeeAllVideos = useCallback(() => {
|
const handleSeeAllVideos = useCallback(() => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||||
params: { type: "Video", title: t("favorites.videos") },
|
params: {
|
||||||
|
type: "Video",
|
||||||
|
title: t(`${seeAllNamespace}.seeAllVideos`),
|
||||||
|
filter,
|
||||||
|
},
|
||||||
} as any);
|
} as any);
|
||||||
}, [router]);
|
}, [router, filter, seeAllNamespace]);
|
||||||
|
|
||||||
const handleSeeAllBoxsets = useCallback(() => {
|
const handleSeeAllBoxsets = useCallback(() => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||||
params: { type: "BoxSet", title: t("favorites.boxsets") },
|
params: {
|
||||||
|
type: "BoxSet",
|
||||||
|
title: t(`${seeAllNamespace}.seeAllBoxsets`),
|
||||||
|
filter,
|
||||||
|
},
|
||||||
} as any);
|
} as any);
|
||||||
}, [router]);
|
}, [router, filter, seeAllNamespace]);
|
||||||
|
|
||||||
const handleSeeAllPlaylists = useCallback(() => {
|
const handleSeeAllPlaylists = useCallback(() => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||||
params: { type: "Playlist", title: t("favorites.playlists") },
|
params: {
|
||||||
|
type: "Playlist",
|
||||||
|
title: t(`${seeAllNamespace}.seeAllPlaylists`),
|
||||||
|
filter,
|
||||||
|
},
|
||||||
} as any);
|
} as any);
|
||||||
}, [router]);
|
}, [router, filter, seeAllNamespace]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-co gap-y-4'>
|
<View className='flex flex-co gap-y-4'>
|
||||||
@@ -176,16 +220,16 @@ export const Favorites = () => {
|
|||||||
source={heart}
|
source={heart}
|
||||||
/>
|
/>
|
||||||
<Text className='text-xl font-semibold text-white mb-2'>
|
<Text className='text-xl font-semibold text-white mb-2'>
|
||||||
{t("favorites.noDataTitle")}
|
{t(emptyTitleKey)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-base text-white/70 text-center max-w-xs px-4'>
|
<Text className='text-base text-white/70 text-center max-w-xs px-4'>
|
||||||
{t("favorites.noData")}
|
{t(emptyTextKey)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteSeries}
|
queryFn={fetchFavoriteSeries}
|
||||||
queryKey={["home", "favorites", "series"]}
|
queryKey={["home", queryKeyBase, "series"]}
|
||||||
title={t("favorites.series")}
|
title={t("favorites.series")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
@@ -193,7 +237,7 @@ export const Favorites = () => {
|
|||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteMovies}
|
queryFn={fetchFavoriteMovies}
|
||||||
queryKey={["home", "favorites", "movies"]}
|
queryKey={["home", queryKeyBase, "movies"]}
|
||||||
title={t("favorites.movies")}
|
title={t("favorites.movies")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
orientation='vertical'
|
orientation='vertical'
|
||||||
@@ -202,7 +246,7 @@ export const Favorites = () => {
|
|||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteEpisodes}
|
queryFn={fetchFavoriteEpisodes}
|
||||||
queryKey={["home", "favorites", "episodes"]}
|
queryKey={["home", queryKeyBase, "episodes"]}
|
||||||
title={t("favorites.episodes")}
|
title={t("favorites.episodes")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
@@ -210,7 +254,7 @@ export const Favorites = () => {
|
|||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteVideos}
|
queryFn={fetchFavoriteVideos}
|
||||||
queryKey={["home", "favorites", "videos"]}
|
queryKey={["home", queryKeyBase, "videos"]}
|
||||||
title={t("favorites.videos")}
|
title={t("favorites.videos")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
@@ -218,7 +262,7 @@ export const Favorites = () => {
|
|||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteBoxsets}
|
queryFn={fetchFavoriteBoxsets}
|
||||||
queryKey={["home", "favorites", "boxsets"]}
|
queryKey={["home", queryKeyBase, "boxsets"]}
|
||||||
title={t("favorites.boxsets")}
|
title={t("favorites.boxsets")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
@@ -226,7 +270,7 @@ export const Favorites = () => {
|
|||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoritePlaylists}
|
queryFn={fetchFavoritePlaylists}
|
||||||
queryKey={["home", "favorites", "playlists"]}
|
queryKey={["home", queryKeyBase, "playlists"]}
|
||||||
title={t("favorites.playlists")}
|
title={t("favorites.playlists")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
import type { Api } from "@jellyfin/sdk";
|
||||||
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
import type {
|
||||||
|
BaseItemKind,
|
||||||
|
ItemFilter,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -9,10 +12,12 @@ import { ScrollView, View } from "react-native";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import heart from "@/assets/icons/heart.fill.png";
|
import heart from "@/assets/icons/heart.fill.png";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVFavoritesTabBadges } from "@/components/favorites/TVFavoritesTabBadges";
|
||||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
const HORIZONTAL_PADDING = 60;
|
const HORIZONTAL_PADDING = 60;
|
||||||
const TOP_PADDING = 100;
|
const TOP_PADDING = 100;
|
||||||
@@ -33,7 +38,27 @@ export const Favorites = () => {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
const { settings } = useSettings();
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
|
|
||||||
|
// KefinTweaks watchlist (Likes-backed) view, toggled in-place like Discover.
|
||||||
|
const watchlistEnabled = settings?.useKefinTweaks ?? false;
|
||||||
|
const [viewType, setViewType] = useState<"Favorites" | "Watchlist">(
|
||||||
|
"Favorites",
|
||||||
|
);
|
||||||
|
const filter: ItemFilter =
|
||||||
|
watchlistEnabled && viewType === "Watchlist" ? "Likes" : "IsFavorite";
|
||||||
|
const queryKeyBase =
|
||||||
|
watchlistEnabled && viewType === "Watchlist" ? "watchlist" : "favorites";
|
||||||
|
// Translation namespace for the empty state, swapped for the KefinTweaks
|
||||||
|
// watchlist (Likes-backed) view. Section titles stay generic ("Series").
|
||||||
|
const emptyNamespace =
|
||||||
|
watchlistEnabled && viewType === "Watchlist"
|
||||||
|
? "kefintweaksWatchlist"
|
||||||
|
: "favorites";
|
||||||
|
const emptyTitleKey = `${emptyNamespace}.noDataTitle`;
|
||||||
|
const emptyTextKey = `${emptyNamespace}.noData`;
|
||||||
|
|
||||||
const [emptyState, setEmptyState] = useState<EmptyState>({
|
const [emptyState, setEmptyState] = useState<EmptyState>({
|
||||||
Series: false,
|
Series: false,
|
||||||
Movie: false,
|
Movie: false,
|
||||||
@@ -53,7 +78,7 @@ export const Favorites = () => {
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
sortBy: ["SeriesSortName", "SortName"],
|
sortBy: ["SeriesSortName", "SortName"],
|
||||||
sortOrder: ["Ascending"],
|
sortOrder: ["Ascending"],
|
||||||
filters: ["IsFavorite"],
|
filters: [filter],
|
||||||
recursive: true,
|
recursive: true,
|
||||||
fields: ["PrimaryImageAspectRatio"],
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
collapseBoxSetItems: false,
|
collapseBoxSetItems: false,
|
||||||
@@ -74,7 +99,7 @@ export const Favorites = () => {
|
|||||||
|
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
[api, user],
|
[api, user, filter],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,7 +111,7 @@ export const Favorites = () => {
|
|||||||
BoxSet: false,
|
BoxSet: false,
|
||||||
Playlist: false,
|
Playlist: false,
|
||||||
});
|
});
|
||||||
}, [api, user]);
|
}, [api, user, viewType]);
|
||||||
|
|
||||||
const areAllEmpty = () => {
|
const areAllEmpty = () => {
|
||||||
const loadedCategories = Object.values(emptyState);
|
const loadedCategories = Object.values(emptyState);
|
||||||
@@ -127,46 +152,63 @@ export const Favorites = () => {
|
|||||||
[fetchFavoritesByType, pageSize],
|
[fetchFavoritesByType, pageSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const tabBadges = (
|
||||||
|
<TVFavoritesTabBadges
|
||||||
|
viewType={viewType}
|
||||||
|
setViewType={setViewType}
|
||||||
|
enabled={watchlistEnabled}
|
||||||
|
hasTVPreferredFocus={watchlistEnabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
if (areAllEmpty()) {
|
if (areAllEmpty()) {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: "center",
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
justifyContent: "center",
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
paddingHorizontal: HORIZONTAL_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
{tabBadges}
|
||||||
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 64,
|
flex: 1,
|
||||||
height: 64,
|
alignItems: "center",
|
||||||
marginBottom: 16,
|
justifyContent: "center",
|
||||||
tintColor: Colors.primary,
|
|
||||||
}}
|
|
||||||
contentFit='contain'
|
|
||||||
source={heart}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: typography.heading,
|
|
||||||
fontWeight: "bold",
|
|
||||||
marginBottom: 8,
|
|
||||||
color: "#FFFFFF",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("favorites.noDataTitle")}
|
<Image
|
||||||
</Text>
|
style={{
|
||||||
<Text
|
width: 64,
|
||||||
style={{
|
height: 64,
|
||||||
textAlign: "center",
|
marginBottom: 16,
|
||||||
opacity: 0.7,
|
tintColor: Colors.primary,
|
||||||
fontSize: typography.body,
|
}}
|
||||||
color: "#FFFFFF",
|
contentFit='contain'
|
||||||
}}
|
source={heart}
|
||||||
>
|
/>
|
||||||
{t("favorites.noData")}
|
<Text
|
||||||
</Text>
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 8,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(emptyTitleKey)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
opacity: 0.7,
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(emptyTextKey)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -181,17 +223,22 @@ export const Favorites = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ gap: SECTION_GAP }}>
|
<View style={{ gap: SECTION_GAP }}>
|
||||||
|
{watchlistEnabled && (
|
||||||
|
<View style={{ paddingHorizontal: HORIZONTAL_PADDING }}>
|
||||||
|
{tabBadges}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteSeries}
|
queryFn={fetchFavoriteSeries}
|
||||||
queryKey={["home", "favorites", "series"]}
|
queryKey={["home", queryKeyBase, "series"]}
|
||||||
title={t("favorites.series")}
|
title={t("favorites.series")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
isFirstSection
|
isFirstSection={!watchlistEnabled}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteMovies}
|
queryFn={fetchFavoriteMovies}
|
||||||
queryKey={["home", "favorites", "movies"]}
|
queryKey={["home", queryKeyBase, "movies"]}
|
||||||
title={t("favorites.movies")}
|
title={t("favorites.movies")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
orientation='vertical'
|
orientation='vertical'
|
||||||
@@ -199,28 +246,28 @@ export const Favorites = () => {
|
|||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteEpisodes}
|
queryFn={fetchFavoriteEpisodes}
|
||||||
queryKey={["home", "favorites", "episodes"]}
|
queryKey={["home", queryKeyBase, "episodes"]}
|
||||||
title={t("favorites.episodes")}
|
title={t("favorites.episodes")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteVideos}
|
queryFn={fetchFavoriteVideos}
|
||||||
queryKey={["home", "favorites", "videos"]}
|
queryKey={["home", queryKeyBase, "videos"]}
|
||||||
title={t("favorites.videos")}
|
title={t("favorites.videos")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteBoxsets}
|
queryFn={fetchFavoriteBoxsets}
|
||||||
queryKey={["home", "favorites", "boxsets"]}
|
queryKey={["home", queryKeyBase, "boxsets"]}
|
||||||
title={t("favorites.boxsets")}
|
title={t("favorites.boxsets")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoritePlaylists}
|
queryFn={fetchFavoritePlaylists}
|
||||||
queryKey={["home", "favorites", "playlists"]}
|
queryKey={["home", queryKeyBase, "playlists"]}
|
||||||
title={t("favorites.playlists")}
|
title={t("favorites.playlists")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
|
|||||||
12
components/navigation/TabBarIcon.tsx
Normal file
12
components/navigation/TabBarIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
||||||
|
|
||||||
|
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
|
||||||
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function TabBarIcon({
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
|
||||||
|
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
||||||
|
}
|
||||||
63
components/posters/EpisodePoster.tsx
Normal file
63
components/posters/EpisodePoster.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
type MoviePosterProps = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EpisodePoster: React.FC<MoviePosterProps> = ({
|
||||||
|
item,
|
||||||
|
showProgress = false,
|
||||||
|
}) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(() => {
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
|
}
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const [progress, _setProgress] = useState(
|
||||||
|
item.UserData?.PlayedPercentage || 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const blurhash = useMemo(() => {
|
||||||
|
const key = item.ImageTags?.Primary as string;
|
||||||
|
return item.ImageBlurHashes?.Primary?.[key];
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
|
||||||
|
<Image
|
||||||
|
placeholder={{
|
||||||
|
blurhash,
|
||||||
|
}}
|
||||||
|
key={item.Id}
|
||||||
|
id={item.Id}
|
||||||
|
source={
|
||||||
|
url
|
||||||
|
? {
|
||||||
|
uri: url,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<WatchedIndicator item={item} />
|
||||||
|
{showProgress && progress > 0 && (
|
||||||
|
<View className='h-1 bg-red-600 w-full' />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
components/posters/ParentPoster.tsx
Normal file
48
components/posters/ParentPoster.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
type PosterProps = {
|
||||||
|
id?: string;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(
|
||||||
|
() => `${api?.basePath}/Items/${id}/Images/Primary`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!url || !id)
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='border border-neutral-900'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='rounded-lg overflow-hidden border border-neutral-900'>
|
||||||
|
<Image
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
source={{
|
||||||
|
uri: url,
|
||||||
|
}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ParentPoster;
|
||||||
@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, View } from "react-native";
|
import { Platform, ScrollView, TextInput, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
||||||
@@ -231,26 +231,48 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
paddingTop: insets.top + TOP_PADDING,
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
|
{/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
|
||||||
module). It renders the native search bar + grid keyboard and
|
TextInput fallback on Android TV (the native module is Apple-only). */}
|
||||||
forwards typed text into the existing query pipeline via setSearch;
|
{Platform.OS === "ios" ? (
|
||||||
our own results grid renders below. */}
|
<View
|
||||||
{/* No horizontal margin here: the native tvOS search bar centers itself
|
style={{
|
||||||
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
marginBottom: 24,
|
||||||
margins squeeze the bar's width and clip that trailing hint, so let
|
height: SEARCH_AREA_HEIGHT,
|
||||||
the native view span the full width and own its own insets. */}
|
}}
|
||||||
<View
|
>
|
||||||
style={{
|
{/* No horizontal margin here: the native tvOS search bar centers
|
||||||
marginBottom: 24,
|
itself and renders a trailing "Hold to Dictate" hint. */}
|
||||||
height: SEARCH_AREA_HEIGHT,
|
<TvSearchView
|
||||||
}}
|
style={{ width: "100%", height: "100%" }}
|
||||||
>
|
placeholder={t("search.search")}
|
||||||
<TvSearchView
|
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
||||||
style={{ width: "100%", height: "100%" }}
|
/>
|
||||||
placeholder={t("search.search")}
|
</View>
|
||||||
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
) : (
|
||||||
/>
|
<View
|
||||||
</View>
|
style={{
|
||||||
|
marginHorizontal: HORIZONTAL_PADDING,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
style={{
|
||||||
|
height: 56,
|
||||||
|
width: "100%",
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
fontSize: 28,
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
placeholder={t("search.search")}
|
||||||
|
placeholderTextColor='rgba(255,255,255,0.4)'
|
||||||
|
onChangeText={setSearch}
|
||||||
|
defaultValue=''
|
||||||
|
autoFocus={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
|||||||
29
components/settings/Dashboard.tsx
Normal file
29
components/settings/Dashboard.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
|
export const Dashboard = () => {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
|
||||||
|
<ListItem
|
||||||
|
className={sessions.length !== 0 ? "bg-purple-900" : ""}
|
||||||
|
onPress={() => router.push("/settings/dashboard/sessions")}
|
||||||
|
title={t("home.settings.dashboard.sessions_title")}
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
3
components/settings/DownloadSettings.tsx
Normal file
3
components/settings/DownloadSettings.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function DownloadSettings() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
3
components/settings/DownloadSettings.tv.tsx
Normal file
3
components/settings/DownloadSettings.tv.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function DownloadSettings() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -115,6 +115,9 @@ export const JellyseerrSettings = () => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||||
|
<Text className='text-xs text-red-600 mb-2'>
|
||||||
|
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
||||||
|
</Text>
|
||||||
<Text className='font-bold mb-1'>
|
<Text className='font-bold mb-1'>
|
||||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as Application from "expo-application";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getVersionInfo } from "@/utils/version";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
@@ -13,10 +13,9 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const version =
|
// Graduated build identifier — see utils/version.ts:
|
||||||
Application?.nativeApplicationVersion ||
|
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit · #run", production → "0.54.1".
|
||||||
Application?.nativeBuildVersion ||
|
const { display: version } = getVersionInfo();
|
||||||
"N/A";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
|
|||||||
36
components/tv/TVWatchlistButton.tsx
Normal file
36
components/tv/TVWatchlistButton.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import React from "react";
|
||||||
|
import { useWatchlist } from "@/hooks/useWatchlist";
|
||||||
|
import { TVButton } from "./TVButton";
|
||||||
|
|
||||||
|
export interface TVWatchlistButtonProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KefinTweaks watchlist toggle (Likes-backed) for TV detail pages.
|
||||||
|
* Render only when settings.useKefinTweaks is enabled.
|
||||||
|
*/
|
||||||
|
export const TVWatchlistButton: React.FC<TVWatchlistButtonProps> = ({
|
||||||
|
item,
|
||||||
|
disabled,
|
||||||
|
}) => {
|
||||||
|
const { isWatchlisted, toggleWatchlist } = useWatchlist(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TVButton
|
||||||
|
onPress={toggleWatchlist}
|
||||||
|
variant='glass'
|
||||||
|
square
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isWatchlisted ? "bookmark" : "bookmark-outline"}
|
||||||
|
size={28}
|
||||||
|
color='#FFFFFF'
|
||||||
|
/>
|
||||||
|
</TVButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -68,3 +68,5 @@ export { TVTrackCard } from "./TVTrackCard";
|
|||||||
// User switching
|
// User switching
|
||||||
export type { TVUserCardProps } from "./TVUserCard";
|
export type { TVUserCardProps } from "./TVUserCard";
|
||||||
export { TVUserCard } from "./TVUserCard";
|
export { TVUserCard } from "./TVUserCard";
|
||||||
|
export type { TVWatchlistButtonProps } from "./TVWatchlistButton";
|
||||||
|
export { TVWatchlistButton } from "./TVWatchlistButton";
|
||||||
|
|||||||
39
constants/Languages.ts
Normal file
39
constants/Languages.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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" },
|
||||||
|
];
|
||||||
8
eas.json
8
eas.json
@@ -97,6 +97,14 @@
|
|||||||
"credentialsSource": "local",
|
"credentialsSource": "local",
|
||||||
"config": "ios-production.yml"
|
"config": "ios-production.yml"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ci": {
|
||||||
|
"extends": "production",
|
||||||
|
"autoIncrement": false
|
||||||
|
},
|
||||||
|
"ci_tv": {
|
||||||
|
"extends": "production_tv",
|
||||||
|
"autoIncrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"submit": {
|
"submit": {
|
||||||
|
|||||||
37
hooks/useControlsVisibility.ts
Normal file
37
hooks/useControlsVisibility.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
|
||||||
|
export const useControlsVisibility = (timeout = 3000) => {
|
||||||
|
const opacity = useSharedValue(1);
|
||||||
|
|
||||||
|
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const showControls = useCallback(() => {
|
||||||
|
opacity.value = 1;
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
hideControlsTimerRef.current = setTimeout(() => {
|
||||||
|
opacity.value = 0;
|
||||||
|
}, timeout);
|
||||||
|
}, [timeout]);
|
||||||
|
|
||||||
|
const hideControls = useCallback(() => {
|
||||||
|
opacity.value = 0;
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hideControlsTimerRef.current) {
|
||||||
|
clearTimeout(hideControlsTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { opacity, showControls, hideControls };
|
||||||
|
};
|
||||||
35
hooks/useDownloadedFileOpener.ts
Normal file
35
hooks/useDownloadedFileOpener.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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 };
|
||||||
|
};
|
||||||
120
hooks/useImageColors.ts
Normal file
120
hooks/useImageColors.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
146
hooks/useWatchlist.ts
Normal file
146
hooks/useWatchlist.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
// Shared atom to store watchlist (Likes) status across all components
|
||||||
|
// Maps itemId -> isWatchlisted
|
||||||
|
const watchlistAtom = atom<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KefinTweaks watchlist is backed by Jellyfin's native "Likes" rating.
|
||||||
|
* Toggling watchlist membership toggles UserData.Likes on the item.
|
||||||
|
*/
|
||||||
|
export const useWatchlist = (item: BaseItemDto) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const [watchlist, setWatchlist] = useAtom(watchlistAtom);
|
||||||
|
|
||||||
|
const itemId = item.Id ?? "";
|
||||||
|
|
||||||
|
// Get current watchlist status from shared state, falling back to item data
|
||||||
|
const isWatchlisted = itemId
|
||||||
|
? (watchlist[itemId] ?? item.UserData?.Likes)
|
||||||
|
: item.UserData?.Likes;
|
||||||
|
|
||||||
|
// Update shared state when item data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (itemId && item.UserData?.Likes !== undefined) {
|
||||||
|
setWatchlist((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[itemId]: item.UserData!.Likes!,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [itemId, item.UserData?.Likes, setWatchlist]);
|
||||||
|
|
||||||
|
// Helper to update watchlist status in shared state
|
||||||
|
const setIsWatchlisted = useCallback(
|
||||||
|
(value: boolean | null | undefined) => {
|
||||||
|
if (itemId && typeof value === "boolean") {
|
||||||
|
setWatchlist((prev) => ({ ...prev, [itemId]: value }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[itemId, setWatchlist],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use refs to avoid stale closure issues in mutationFn
|
||||||
|
const itemRef = useRef(item);
|
||||||
|
const apiRef = useRef(api);
|
||||||
|
const userRef = useRef(user);
|
||||||
|
|
||||||
|
// Keep refs updated
|
||||||
|
useEffect(() => {
|
||||||
|
itemRef.current = item;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiRef.current = api;
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
userRef.current = user;
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const itemQueryKeyPrefix = useMemo(
|
||||||
|
() => ["item", item.Id] as const,
|
||||||
|
[item.Id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateItemInQueries = useCallback(
|
||||||
|
(newData: Partial<BaseItemDto>) => {
|
||||||
|
queryClient.setQueriesData<BaseItemDto | null | undefined>(
|
||||||
|
{ queryKey: itemQueryKeyPrefix },
|
||||||
|
(old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
...newData,
|
||||||
|
UserData: { ...old.UserData, ...newData.UserData },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[itemQueryKeyPrefix, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const watchlistMutation = useMutation({
|
||||||
|
mutationFn: async (nextIsWatchlisted: boolean) => {
|
||||||
|
const currentApi = apiRef.current;
|
||||||
|
const currentUser = userRef.current;
|
||||||
|
const currentItem = itemRef.current;
|
||||||
|
|
||||||
|
if (!currentApi || !currentUser?.Id || !currentItem?.Id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watchlist == Jellyfin "Likes" rating:
|
||||||
|
// POST /UserItems/{itemId}/Rating?userId={userId}&likes=true - add to watchlist
|
||||||
|
// POST /UserItems/{itemId}/Rating?userId={userId}&likes=false - remove from watchlist
|
||||||
|
const path = `/UserItems/${currentItem.Id}/Rating`;
|
||||||
|
|
||||||
|
const response = await currentApi.post(
|
||||||
|
path,
|
||||||
|
{},
|
||||||
|
{ params: { userId: currentUser.Id, likes: nextIsWatchlisted } },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onMutate: async (nextIsWatchlisted: boolean) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: itemQueryKeyPrefix });
|
||||||
|
|
||||||
|
const previousIsWatchlisted = isWatchlisted;
|
||||||
|
const previousQueries = queryClient.getQueriesData<BaseItemDto | null>({
|
||||||
|
queryKey: itemQueryKeyPrefix,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsWatchlisted(nextIsWatchlisted);
|
||||||
|
updateItemInQueries({ UserData: { Likes: nextIsWatchlisted } });
|
||||||
|
|
||||||
|
return { previousIsWatchlisted, previousQueries };
|
||||||
|
},
|
||||||
|
onError: (_err, _nextIsWatchlisted, context) => {
|
||||||
|
if (context?.previousQueries) {
|
||||||
|
for (const [queryKey, data] of context.previousQueries) {
|
||||||
|
queryClient.setQueryData(queryKey, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsWatchlisted(context?.previousIsWatchlisted);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: itemQueryKeyPrefix });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["home", "watchlist"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleWatchlist = useCallback(() => {
|
||||||
|
watchlistMutation.mutate(!isWatchlisted);
|
||||||
|
}, [watchlistMutation, isWatchlisted]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isWatchlisted,
|
||||||
|
toggleWatchlist,
|
||||||
|
watchlistMutation,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -53,6 +53,7 @@ export function useWifiSSID(): UseWifiSSIDReturn {
|
|||||||
const fetchSSID = useCallback(async () => {
|
const fetchSSID = useCallback(async () => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
const result = await getSSID();
|
const result = await getSSID();
|
||||||
|
console.log("[WiFi Debug] Native module SSID:", result);
|
||||||
setSSID(result);
|
setSSID(result);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application>
|
<application>
|
||||||
<receiver
|
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
|
||||||
android:name=".TvRecommendationsReceiver"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
|
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ import androidx.tvprovider.media.tv.PreviewProgram
|
|||||||
import androidx.tvprovider.media.tv.TvContractCompat
|
import androidx.tvprovider.media.tv.TvContractCompat
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
internal object TvRecommendationsPublisher {
|
internal object TvRecommendationsPublisher {
|
||||||
private const val TAG = "TvRecommendations"
|
private const val TAG = "TvRecommendations"
|
||||||
private const val PREFS_NAME = "StreamyfinTvRecommendations"
|
private const val PREFS_NAME = "StreamyfinTvRecommendations"
|
||||||
private const val KEY_PAYLOAD = "payload"
|
private const val KEY_PAYLOAD = "payload"
|
||||||
private const val KEY_CHANNEL_ID = "channelId"
|
private const val KEY_CHANNEL_ID_PREFIX = "channelId_"
|
||||||
private const val KEY_PROGRAM_IDS = "programIds"
|
private const val KEY_PROGRAM_IDS = "programIds"
|
||||||
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
|
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
|
||||||
|
|
||||||
@@ -61,31 +62,61 @@ internal object TvRecommendationsPublisher {
|
|||||||
|
|
||||||
fun clear(context: Context): Boolean {
|
fun clear(context: Context): Boolean {
|
||||||
val prefs = preferences(context)
|
val prefs = preferences(context)
|
||||||
val channelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
|
||||||
val programIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
if (programIds != null) {
|
// KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" }
|
||||||
|
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
||||||
|
if (allProgramIds != null) {
|
||||||
var deletedPrograms = 0
|
var deletedPrograms = 0
|
||||||
val keys = programIds.keys()
|
val channelKeys = allProgramIds.keys()
|
||||||
while (keys.hasNext()) {
|
while (channelKeys.hasNext()) {
|
||||||
val key = keys.next()
|
val channelIdStr = channelKeys.next()
|
||||||
val programId = programIds.optLong(key, -1L)
|
val programIdsJson = allProgramIds.optString(channelIdStr)
|
||||||
if (programId > 0L) {
|
if (programIdsJson.isBlank()) continue
|
||||||
contentResolver.delete(
|
|
||||||
TvContractCompat.buildPreviewProgramUri(programId),
|
try {
|
||||||
null,
|
val programIds = JSONObject(programIdsJson)
|
||||||
null
|
val keys = programIds.keys()
|
||||||
)
|
while (keys.hasNext()) {
|
||||||
deletedPrograms += 1
|
val providerId = keys.next()
|
||||||
|
val programId = programIds.optLong(providerId, -1L)
|
||||||
|
if (programId > 0L) {
|
||||||
|
deletePreviewProgram(contentResolver, programId)
|
||||||
|
deletedPrograms += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "clear(): failed to parse programIds for channel $channelIdStr", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify the channel
|
||||||
|
val channelId = channelIdStr.toLongOrNull() ?: -1L
|
||||||
|
if (channelId > 0L) {
|
||||||
|
try {
|
||||||
|
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "clear(): lost provider permission, cannot notify channel $channelId", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove per-channel pref
|
||||||
|
prefs.edit().remove("programIds_$channelIdStr").apply()
|
||||||
}
|
}
|
||||||
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
|
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channelId > 0L) {
|
// Also handle legacy format (flat { providerId: programId }) for migration
|
||||||
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
|
val legacyProgramIds = prefs.getString("legacy_" + KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
||||||
Log.d(TAG, "clear(): notified channel $channelId")
|
if (legacyProgramIds != null) {
|
||||||
|
val keys = legacyProgramIds.keys()
|
||||||
|
while (keys.hasNext()) {
|
||||||
|
val key = keys.next()
|
||||||
|
val programId = legacyProgramIds.optLong(key, -1L)
|
||||||
|
if (programId > 0L) {
|
||||||
|
deletePreviewProgram(contentResolver, programId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs.edit().remove("legacy_" + KEY_PROGRAM_IDS).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
@@ -96,128 +127,274 @@ internal object TvRecommendationsPublisher {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a single preview program from the TvProvider.
|
||||||
|
* Called by clear(), synchronize() (stale programs), and TvRecommendationsReceiver (user removed).
|
||||||
|
*/
|
||||||
|
fun deletePreviewProgram(context: Context, programId: Long) {
|
||||||
|
try {
|
||||||
|
context.contentResolver.delete(
|
||||||
|
TvContractCompat.buildPreviewProgramUri(programId),
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
Log.d(TAG, "deletePreviewProgram(): deleted programId=$programId")
|
||||||
|
|
||||||
|
// Also remove from stored programIds prefs
|
||||||
|
removeProgramFromPrefs(context, programId)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deletePreviewProgram(contentResolver: android.content.ContentResolver, programId: Long) {
|
||||||
|
try {
|
||||||
|
contentResolver.delete(
|
||||||
|
TvContractCompat.buildPreviewProgramUri(programId),
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeProgramFromPrefs(context: Context, programId: Long) {
|
||||||
|
val prefs = preferences(context)
|
||||||
|
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
|
||||||
|
try {
|
||||||
|
val channelMap = JSONObject(programIdsJson)
|
||||||
|
val channelKeys = channelMap.keys()
|
||||||
|
while (channelKeys.hasNext()) {
|
||||||
|
val channelId = channelKeys.next()
|
||||||
|
val inner = channelMap.optJSONObject(channelId) ?: continue
|
||||||
|
val providerKeys = inner.keys()
|
||||||
|
while (providerKeys.hasNext()) {
|
||||||
|
val providerId = providerKeys.next()
|
||||||
|
if (inner.optLong(providerId, -1L) == programId) {
|
||||||
|
inner.remove(providerId)
|
||||||
|
if (inner.length() == 0) {
|
||||||
|
channelMap.remove(channelId)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs.edit().putString(KEY_PROGRAM_IDS, channelMap.toString()).apply()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun synchronize(context: Context, payload: JSONObject): Boolean {
|
private fun synchronize(context: Context, payload: JSONObject): Boolean {
|
||||||
val sections = payload.optJSONArray("sections") ?: JSONArray()
|
val sections = payload.optJSONArray("sections") ?: JSONArray()
|
||||||
val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null
|
if (sections.length() == 0) {
|
||||||
val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
|
Log.w(TAG, "synchronize(): no sections in payload")
|
||||||
val items = firstSection?.optJSONArray("items") ?: JSONArray()
|
|
||||||
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
val channelId = getOrCreateChannel(context, sectionTitle)
|
|
||||||
if (channelId <= 0L) {
|
|
||||||
Log.w(TAG, "synchronize(): failed to get or create preview channel")
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "synchronize(): publishing into channelId=$channelId")
|
val prefs = preferences(context)
|
||||||
|
val allNextProgramIds = JSONObject()
|
||||||
|
var totalActive = 0
|
||||||
|
var totalDeleted = 0
|
||||||
|
|
||||||
val previousProgramIds = preferences(context)
|
for (sectionIndex in 0 until sections.length()) {
|
||||||
.getString(KEY_PROGRAM_IDS, null)
|
val section = sections.optJSONObject(sectionIndex) ?: continue
|
||||||
?.let(::JSONObject)
|
val sectionTitle = section.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
|
||||||
?: JSONObject()
|
val items = section.optJSONArray("items") ?: JSONArray()
|
||||||
val nextProgramIds = JSONObject()
|
|
||||||
val activeProviderIds = mutableSetOf<String>()
|
|
||||||
|
|
||||||
for (index in 0 until items.length()) {
|
Log.d(
|
||||||
val item = items.optJSONObject(index) ?: continue
|
TAG,
|
||||||
val providerId = item.optString("id")
|
"synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
|
||||||
if (providerId.isBlank()) continue
|
|
||||||
|
|
||||||
val programId = upsertPreviewProgram(
|
|
||||||
context = context,
|
|
||||||
channelId = channelId,
|
|
||||||
item = item,
|
|
||||||
previousProgramId = previousProgramIds.optLong(providerId, -1L),
|
|
||||||
weight = index
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (programId > 0L) {
|
val channelId = getOrCreateChannel(context, sectionTitle)
|
||||||
activeProviderIds += providerId
|
if (channelId <= 0L) {
|
||||||
nextProgramIds.put(providerId, programId)
|
Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
|
||||||
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
|
continue
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var deletedPrograms = 0
|
// Per Android docs: check channel.isBrowsable() and request if needed.
|
||||||
val previousKeys = previousProgramIds.keys()
|
if (!isChannelBrowsable(context, channelId)) {
|
||||||
while (previousKeys.hasNext()) {
|
Log.d(TAG, "synchronize(): channel $channelId not browsable, requesting browsable")
|
||||||
val providerId = previousKeys.next()
|
TvContractCompat.requestChannelBrowsable(context, channelId)
|
||||||
if (activeProviderIds.contains(providerId)) continue
|
}
|
||||||
|
|
||||||
val programId = previousProgramIds.optLong(providerId, -1L)
|
val prefKey = "programIds_$channelId"
|
||||||
if (programId > 0L) {
|
val previousProgramIds = prefs.getString(prefKey, null)
|
||||||
context.contentResolver.delete(
|
?.let(::JSONObject)
|
||||||
TvContractCompat.buildPreviewProgramUri(programId),
|
?: JSONObject()
|
||||||
null,
|
val nextProgramIds = JSONObject()
|
||||||
null
|
val activeProviderIds = mutableSetOf<String>()
|
||||||
|
|
||||||
|
for (index in 0 until items.length()) {
|
||||||
|
val item = items.optJSONObject(index) ?: continue
|
||||||
|
val providerId = item.optString("id")
|
||||||
|
if (providerId.isBlank()) continue
|
||||||
|
|
||||||
|
val programId = upsertPreviewProgram(
|
||||||
|
context = context,
|
||||||
|
channelId = channelId,
|
||||||
|
item = item,
|
||||||
|
previousProgramId = previousProgramIds.optLong(providerId, -1L),
|
||||||
|
weight = index
|
||||||
)
|
)
|
||||||
deletedPrograms += 1
|
|
||||||
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
|
if (programId > 0L) {
|
||||||
|
activeProviderIds += providerId
|
||||||
|
nextProgramIds.put(providerId, programId)
|
||||||
|
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var deletedPrograms = 0
|
||||||
|
val previousKeys = previousProgramIds.keys()
|
||||||
|
while (previousKeys.hasNext()) {
|
||||||
|
val providerId = previousKeys.next()
|
||||||
|
if (activeProviderIds.contains(providerId)) continue
|
||||||
|
|
||||||
|
val programId = previousProgramIds.optLong(providerId, -1L)
|
||||||
|
if (programId > 0L) {
|
||||||
|
deletePreviewProgram(context, programId)
|
||||||
|
deletedPrograms += 1
|
||||||
|
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allNextProgramIds.put(channelId.toString(), nextProgramIds.toString())
|
||||||
|
prefs.edit().putString(prefKey, nextProgramIds.toString()).apply()
|
||||||
|
totalActive += activeProviderIds.size
|
||||||
|
totalDeleted += deletedPrograms
|
||||||
|
|
||||||
|
logProviderState(context, channelId)
|
||||||
}
|
}
|
||||||
|
|
||||||
preferences(context)
|
// Store all channel program IDs for clear() to use
|
||||||
.edit()
|
prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
|
||||||
.putLong(KEY_CHANNEL_ID, channelId)
|
|
||||||
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString())
|
|
||||||
.apply()
|
|
||||||
|
|
||||||
logProviderState(context, channelId)
|
|
||||||
|
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)"
|
"synchronize(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted stale program(s)"
|
||||||
)
|
)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query provider to check if a channel is browsable.
|
||||||
|
* Per Android docs: "check channel.isBrowsable() before updating programs."
|
||||||
|
*/
|
||||||
|
private fun isChannelBrowsable(context: Context, channelId: Long): Boolean {
|
||||||
|
return try {
|
||||||
|
context.contentResolver.query(
|
||||||
|
TvContractCompat.buildChannelUri(channelId),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE)
|
||||||
|
if (browsableIndex >= 0) cursor.getInt(browsableIndex) == 1 else true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} ?: false
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "isChannelBrowsable(): lost provider permission for channelId=$channelId", e)
|
||||||
|
true // Assume browsable if we can't check, to avoid blocking updates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query provider to verify a channel actually exists.
|
||||||
|
* Prevents the channel-delete-recreate bug: if update() returns 0 rows
|
||||||
|
* we must first check whether the channel was deleted by the system
|
||||||
|
* or if the update simply failed for another reason.
|
||||||
|
*/
|
||||||
|
private fun channelExistsInProvider(context: Context, channelId: Long): Boolean {
|
||||||
|
return try {
|
||||||
|
context.contentResolver.query(
|
||||||
|
TvContractCompat.buildChannelUri(channelId),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)?.use { cursor ->
|
||||||
|
cursor.moveToFirst()
|
||||||
|
} ?: false
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "channelExistsInProvider(): lost provider permission for channelId=$channelId", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun getOrCreateChannel(context: Context, displayName: String): Long {
|
private fun getOrCreateChannel(context: Context, displayName: String): Long {
|
||||||
val prefs = preferences(context)
|
val prefs = preferences(context)
|
||||||
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
val channelKey = getChannelKey(displayName)
|
||||||
|
val existingChannelId = prefs.getLong(channelKey, -1L)
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
if (existingChannelId > 0L) {
|
if (existingChannelId > 0L) {
|
||||||
val updated = Channel.Builder()
|
// Query provider first to verify channel actually exists (prevents recreate bug)
|
||||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
val exists = channelExistsInProvider(context, existingChannelId)
|
||||||
.setDisplayName(displayName)
|
|
||||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val updatedRows = contentResolver.update(
|
if (exists) {
|
||||||
TvContractCompat.buildChannelUri(existingChannelId),
|
// Channel exists — update it in place, never recreate
|
||||||
updated.toContentValues(),
|
val updated = Channel.Builder()
|
||||||
null,
|
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||||
null
|
.setDisplayName(displayName)
|
||||||
)
|
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||||
|
.build()
|
||||||
|
|
||||||
if (updatedRows > 0) {
|
try {
|
||||||
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
|
val updatedRows = contentResolver.update(
|
||||||
storeChannelLogo(context, existingChannelId)
|
TvContractCompat.buildChannelUri(existingChannelId),
|
||||||
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
|
updated.toContentValues(),
|
||||||
return existingChannelId
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
if (updatedRows > 0) {
|
||||||
|
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
|
||||||
|
storeChannelLogo(context, existingChannelId)
|
||||||
|
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
|
||||||
|
return existingChannelId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update returned 0 rows but channel exists — log and return existing ID, don't recreate
|
||||||
|
Log.e(TAG, "getOrCreateChannel(): update returned 0 for existing channelId=$existingChannelId but channel exists — not recreating")
|
||||||
|
return existingChannelId
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "getOrCreateChannel(): lost provider permission updating channelId=$existingChannelId", e)
|
||||||
|
return existingChannelId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating")
|
// Channel truly doesn't exist in provider — recreate
|
||||||
prefs.edit().remove(KEY_CHANNEL_ID).apply()
|
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
|
||||||
|
prefs.edit().remove(channelKey).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a new channel
|
||||||
val channel = Channel.Builder()
|
val channel = Channel.Builder()
|
||||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||||
.setDisplayName(displayName)
|
.setDisplayName(displayName)
|
||||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val channelUri = contentResolver.insert(
|
val channelUri = try {
|
||||||
TvContractCompat.Channels.CONTENT_URI,
|
contentResolver.insert(
|
||||||
channel.toContentValues()
|
TvContractCompat.Channels.CONTENT_URI,
|
||||||
) ?: return -1L
|
channel.toContentValues()
|
||||||
|
)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "getOrCreateChannel(): lost provider permission, cannot create channel", e)
|
||||||
|
null
|
||||||
|
} ?: return -1L
|
||||||
|
|
||||||
val channelId = ContentUris.parseId(channelUri)
|
val channelId = ContentUris.parseId(channelUri)
|
||||||
|
prefs.edit().putLong(channelKey, channelId).apply()
|
||||||
TvContractCompat.requestChannelBrowsable(context, channelId)
|
TvContractCompat.requestChannelBrowsable(context, channelId)
|
||||||
storeChannelLogo(context, channelId)
|
storeChannelLogo(context, channelId)
|
||||||
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
|
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
|
||||||
@@ -225,6 +402,10 @@ internal object TvRecommendationsPublisher {
|
|||||||
return channelId
|
return channelId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getChannelKey(displayName: String): String {
|
||||||
|
return KEY_CHANNEL_ID_PREFIX + displayName.hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
private fun upsertPreviewProgram(
|
private fun upsertPreviewProgram(
|
||||||
context: Context,
|
context: Context,
|
||||||
channelId: Long,
|
channelId: Long,
|
||||||
@@ -249,42 +430,67 @@ internal object TvRecommendationsPublisher {
|
|||||||
builder.setDescription(it)
|
builder.setDescription(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per Android docs: use unique URIs for all images to avoid stale cache
|
||||||
imageUrl.takeIf { it.isNotBlank() }?.let {
|
imageUrl.takeIf { it.isNotBlank() }?.let {
|
||||||
val imageUri = Uri.parse(it)
|
val uniqueImageUrl = appendCacheBuster(it)
|
||||||
|
val imageUri = Uri.parse(uniqueImageUrl)
|
||||||
builder.setPosterArtUri(imageUri)
|
builder.setPosterArtUri(imageUri)
|
||||||
builder.setThumbnailUri(imageUri)
|
builder.setThumbnailUri(imageUri)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val contentValues = builder.build().toContentValues()
|
val contentValues = builder.build().toContentValues()
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
if (previousProgramId > 0L) {
|
if (previousProgramId > 0L) {
|
||||||
val updatedRows = contentResolver.update(
|
try {
|
||||||
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
val updatedRows = contentResolver.update(
|
||||||
contentValues,
|
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
||||||
null,
|
contentValues,
|
||||||
null
|
null,
|
||||||
)
|
null
|
||||||
|
)
|
||||||
|
|
||||||
if (updatedRows > 0) {
|
if (updatedRows > 0) {
|
||||||
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
|
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
|
||||||
return previousProgramId
|
return previousProgramId
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "upsertPreviewProgram(): lost provider permission for programId=$previousProgramId", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val insertedUri = contentResolver.insert(
|
val insertedUri = try {
|
||||||
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
contentResolver.insert(
|
||||||
contentValues
|
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||||
) ?: return -1L
|
contentValues
|
||||||
|
)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "upsertPreviewProgram(): lost provider permission, cannot insert program", e)
|
||||||
|
null
|
||||||
|
} ?: return -1L
|
||||||
|
|
||||||
val programId = ContentUris.parseId(insertedUri)
|
val programId = ContentUris.parseId(insertedUri)
|
||||||
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
|
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
|
||||||
return programId
|
return programId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a stable cache key derived from the image URL.
|
||||||
|
* The Jellyfin image URLs already include a `tag=` query param (etag)
|
||||||
|
* that changes whenever the image content changes, so a deterministic
|
||||||
|
* hash of the URL is sufficient — the param only changes when the URL
|
||||||
|
* (and therefore the image) actually changes, avoiding unnecessary
|
||||||
|
* re-downloads on every sync.
|
||||||
|
*/
|
||||||
|
private fun appendCacheBuster(imageUrl: String): String {
|
||||||
|
val digest = MessageDigest.getInstance("MD5").digest(imageUrl.toByteArray())
|
||||||
|
val hash = digest.joinToString("") { "%02x".format(it) }.substring(0, 16)
|
||||||
|
val separator = if (imageUrl.contains("?")) "&" else "?"
|
||||||
|
return "$imageUrl${separator}_v=$hash"
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildIntentUri(context: Context, deepLink: String): Uri {
|
private fun buildIntentUri(context: Context, deepLink: String): Uri {
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
data = Uri.parse(deepLink)
|
data = Uri.parse(deepLink)
|
||||||
@@ -306,13 +512,17 @@ internal object TvRecommendationsPublisher {
|
|||||||
|
|
||||||
private fun storeChannelLogo(context: Context, channelId: Long) {
|
private fun storeChannelLogo(context: Context, channelId: Long) {
|
||||||
val bitmap = applicationIconBitmap(context) ?: return
|
val bitmap = applicationIconBitmap(context) ?: return
|
||||||
val outputStream = context.contentResolver.openOutputStream(
|
try {
|
||||||
TvContractCompat.buildChannelLogoUri(channelId)
|
val outputStream = context.contentResolver.openOutputStream(
|
||||||
) ?: return
|
TvContractCompat.buildChannelLogoUri(channelId)
|
||||||
|
) ?: return
|
||||||
|
|
||||||
outputStream.use { stream ->
|
outputStream.use { stream ->
|
||||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||||
stream.flush()
|
stream.flush()
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "storeChannelLogo(): lost provider permission for channelId=$channelId", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,9 +551,14 @@ internal object TvRecommendationsPublisher {
|
|||||||
return bitmap
|
return bitmap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getChannelId(context: Context, displayName: String = DEFAULT_CHANNEL_NAME): Long {
|
||||||
|
return preferences(context).getLong(getChannelKey(displayName), -1L)
|
||||||
|
}
|
||||||
|
|
||||||
private fun preferences(context: Context): SharedPreferences {
|
private fun preferences(context: Context): SharedPreferences {
|
||||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun logProviderState(context: Context, channelId: Long) {
|
private fun logProviderState(context: Context, channelId: Long) {
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
@@ -372,8 +587,10 @@ internal object TvRecommendationsPublisher {
|
|||||||
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
|
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
|
||||||
}
|
}
|
||||||
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
|
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
|
||||||
} catch (error: Exception) {
|
} catch (error: SecurityException) {
|
||||||
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
|
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
|
||||||
}
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,24 @@ package expo.modules.tvrecommendations
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.ContentUris
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.tvprovider.media.tv.TvContractCompat
|
import androidx.tvprovider.media.tv.TvContractCompat
|
||||||
|
|
||||||
class TvRecommendationsReceiver : BroadcastReceiver() {
|
class TvRecommendationsReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) {
|
when (intent.action) {
|
||||||
return
|
TvContractCompat.ACTION_INITIALIZE_PROGRAMS -> {
|
||||||
|
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
|
||||||
|
TvRecommendationsPublisher.refreshFromCache(context)
|
||||||
|
}
|
||||||
|
"android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" -> {
|
||||||
|
val programId = intent.data?.let { ContentUris.parseId(it) } ?: -1L
|
||||||
|
if (programId > 0L) {
|
||||||
|
Log.d("TvRecommendations", "Handling PREVIEW_PROGRAM_BROWSABLE_DISABLED for programId=$programId")
|
||||||
|
TvRecommendationsPublisher.deletePreviewProgram(context, programId)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
|
|
||||||
TvRecommendationsPublisher.refreshFromCache(context)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { requireNativeView } from "expo";
|
import { requireNativeView } from "expo";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import type { View } from "react-native";
|
import type { View } from "react-native";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
import type { TvSearchViewProps } from "./TvSearchView.types";
|
import type { TvSearchViewProps } from "./TvSearchView.types";
|
||||||
|
|
||||||
|
// The native TvSearchModule is Apple-only (tvOS SwiftUI `.searchable`).
|
||||||
|
// On Android the component is never rendered, but we must avoid calling
|
||||||
|
// `requireNativeView` at module-scope because it would crash on import.
|
||||||
const NativeView: React.ComponentType<
|
const NativeView: React.ComponentType<
|
||||||
TvSearchViewProps & React.RefAttributes<View>
|
TvSearchViewProps & React.RefAttributes<View>
|
||||||
> = requireNativeView("TvSearchModule");
|
> =
|
||||||
|
Platform.OS === "ios"
|
||||||
|
? requireNativeView("TvSearchModule")
|
||||||
|
: ((() => null) as any);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forwards its ref to the underlying native view so it can be used as a
|
* Forwards its ref to the underlying native view so it can be used as a
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const WifiSsidModule =
|
|||||||
*/
|
*/
|
||||||
export async function getSSID(): Promise<string | null> {
|
export async function getSSID(): Promise<string | null> {
|
||||||
if (!WifiSsidModule) {
|
if (!WifiSsidModule) {
|
||||||
|
console.log("[WifiSsid] Module not available on this platform");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,12 +142,31 @@ export function useDownloadEventHandlers({
|
|||||||
} else {
|
} else {
|
||||||
// Transcoding - estimate from bitrate
|
// Transcoding - estimate from bitrate
|
||||||
const process = processes.find((p) => p.id === processId);
|
const process = processes.find((p) => p.id === processId);
|
||||||
if (process?.maxBitrate.value && process.item.RunTimeTicks) {
|
console.log(
|
||||||
const { estimateDownloadSize } = require("@/utils/download");
|
`[DPL] Transcoding detected, looking for process ${processId}, found:`,
|
||||||
estimatedTotalBytes = estimateDownloadSize(
|
process ? "yes" : "no",
|
||||||
process.maxBitrate.value,
|
);
|
||||||
process.item.RunTimeTicks,
|
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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from "@/utils/secureCredentials";
|
} from "@/utils/secureCredentials";
|
||||||
import { store } from "@/utils/store";
|
import { store } from "@/utils/store";
|
||||||
import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync";
|
import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync";
|
||||||
|
import { APP_VERSION } from "@/utils/version";
|
||||||
|
|
||||||
interface Server {
|
interface Server {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -53,7 +54,7 @@ const initialApi = (() => {
|
|||||||
const id = getOrSetDeviceId();
|
const id = getOrSetDeviceId();
|
||||||
const deviceName = getDeviceNameSync();
|
const deviceName = getDeviceNameSync();
|
||||||
const jellyfinInstance = new Jellyfin({
|
const jellyfinInstance = new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
clientInfo: { name: "Streamyfin", version: APP_VERSION },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -135,7 +136,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const id = getOrSetDeviceId();
|
const id = getOrSetDeviceId();
|
||||||
const deviceName = getDeviceNameSync();
|
const deviceName = getDeviceNameSync();
|
||||||
return new Jellyfin({
|
return new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
clientInfo: { name: "Streamyfin", version: APP_VERSION },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -169,7 +170,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.54.1"`,
|
}, DeviceId="${deviceId}", Version="${APP_VERSION}"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@
|
|||||||
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software, which you can find in the settings menu. These include:",
|
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software, which you can find in the settings menu. These include:",
|
||||||
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
|
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
|
||||||
"downloads_feature_title": "Downloads",
|
"downloads_feature_title": "Downloads",
|
||||||
"downloads_feature_description": "Download movies and series to watch offline.",
|
"downloads_feature_description": "Download movies and series to watch offline. Use either the default method or install the optimize server to download files in the background.",
|
||||||
"chromecast_feature_description": "Cast movies and series to your Chromecast devices.",
|
"chromecast_feature_description": "Cast movies and series to your Chromecast devices.",
|
||||||
"centralised_settings_plugin_title": "Centralised Settings plugin",
|
"centralised_settings_plugin_title": "Centralised Settings plugin",
|
||||||
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
|
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
|
||||||
@@ -320,6 +320,7 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Plugins",
|
"plugins_title": "Plugins",
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
|
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
|
||||||
"server_url": "Server URL",
|
"server_url": "Server URL",
|
||||||
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
||||||
"server_url_placeholder": "Seerr URL",
|
"server_url_placeholder": "Seerr URL",
|
||||||
@@ -431,6 +432,10 @@
|
|||||||
"4_hours": "4 hours",
|
"4_hours": "4 hours",
|
||||||
"24_hours": "24 hours"
|
"24_hours": "24 hours"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"sessions_title": "Sessions"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
@@ -588,8 +593,25 @@
|
|||||||
"videos": "Videos",
|
"videos": "Videos",
|
||||||
"boxsets": "Box sets",
|
"boxsets": "Box sets",
|
||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
|
"seeAllSeries": "Favorited Series",
|
||||||
|
"seeAllMovies": "Favorited Movies",
|
||||||
|
"seeAllEpisodes": "Favorited Episodes",
|
||||||
|
"seeAllVideos": "Favorited Videos",
|
||||||
|
"seeAllBoxsets": "Favorited Box sets",
|
||||||
|
"seeAllPlaylists": "Favorited Playlists",
|
||||||
"noDataTitle": "No favorites yet",
|
"noDataTitle": "No favorites yet",
|
||||||
"noData": "Mark items as favorites to see them appear here for quick access."
|
"noData": "Mark items as favorites to see them appear here for quick access.",
|
||||||
|
"watchlist": "Watchlist"
|
||||||
|
},
|
||||||
|
"kefintweaksWatchlist": {
|
||||||
|
"seeAllSeries": "Watchlisted Series",
|
||||||
|
"seeAllMovies": "Watchlisted Movies",
|
||||||
|
"seeAllEpisodes": "Watchlisted Episodes",
|
||||||
|
"seeAllVideos": "Watchlisted Videos",
|
||||||
|
"seeAllBoxsets": "Watchlisted Box sets",
|
||||||
|
"seeAllPlaylists": "Watchlisted Playlists",
|
||||||
|
"noDataTitle": "No watchlisted items yet",
|
||||||
|
"noData": "Add items to your watchlist to see them appear here."
|
||||||
},
|
},
|
||||||
"custom_links": {
|
"custom_links": {
|
||||||
"no_links": "No links"
|
"no_links": "No links"
|
||||||
|
|||||||
@@ -675,8 +675,25 @@
|
|||||||
"videos": "Videor",
|
"videos": "Videor",
|
||||||
"boxsets": "Box Set",
|
"boxsets": "Box Set",
|
||||||
"playlists": "Spellistor",
|
"playlists": "Spellistor",
|
||||||
|
"seeAllSeries": "Favoritmarkerade serier",
|
||||||
|
"seeAllMovies": "Favoritmarkerade filmer",
|
||||||
|
"seeAllEpisodes": "Favoritmarkerade avsnitt",
|
||||||
|
"seeAllVideos": "Favoritmarkerade videor",
|
||||||
|
"seeAllBoxsets": "Favoritmarkerade box set",
|
||||||
|
"seeAllPlaylists": "Favoritmarkerade spellistor",
|
||||||
"noDataTitle": "Inga favoriter än",
|
"noDataTitle": "Inga favoriter än",
|
||||||
"noData": "Markera objekt som favoriter för att se dem visas här för snabb åtkomst."
|
"noData": "Markera objekt som favoriter för att se dem visas här för snabb åtkomst.",
|
||||||
|
"watchlist": "Bevakningslista"
|
||||||
|
},
|
||||||
|
"kefintweaksWatchlist": {
|
||||||
|
"seeAllSeries": "Bevakade serier",
|
||||||
|
"seeAllMovies": "Bevakade filmer",
|
||||||
|
"seeAllEpisodes": "Bevakade avsnitt",
|
||||||
|
"seeAllVideos": "Bevakade videor",
|
||||||
|
"seeAllBoxsets": "Bevakade box set",
|
||||||
|
"seeAllPlaylists": "Bevakade spellistor",
|
||||||
|
"noDataTitle": "Inga bevakade objekt än",
|
||||||
|
"noData": "Lägg till objekt i din bevakningslista för att se dem visas här."
|
||||||
},
|
},
|
||||||
"custom_links": {
|
"custom_links": {
|
||||||
"no_links": "Inga Länkar"
|
"no_links": "Inga Länkar"
|
||||||
|
|||||||
@@ -82,8 +82,6 @@ export const useFilterOptions = () => {
|
|||||||
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
||||||
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
||||||
];
|
];
|
||||||
console.log("filterOptions");
|
|
||||||
console.log(filterOptions);
|
|
||||||
return filterOptions;
|
return filterOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
18
utils/bToMb.ts
Normal file
18
utils/bToMb.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Convert bits to megabits or gigabits
|
||||||
|
*
|
||||||
|
* Return nice looking string
|
||||||
|
* If under 1000Mb, return XXXMB, else return X.XGB
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function convertBitsToMegabitsOrGigabits(bits?: number | null): string {
|
||||||
|
if (!bits) return "0MB";
|
||||||
|
|
||||||
|
const megabits = bits / 1000000;
|
||||||
|
|
||||||
|
if (megabits < 1000) {
|
||||||
|
return `${Math.round(megabits)}MB`;
|
||||||
|
}
|
||||||
|
const gigabits = megabits / 1000;
|
||||||
|
return `${gigabits.toFixed(1)}GB`;
|
||||||
|
}
|
||||||
47
utils/collectionTypeToItemType.ts
Normal file
47
utils/collectionTypeToItemType.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
BaseItemKind,
|
||||||
|
CollectionType,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a ColletionType to a BaseItemKind (also called ItemType)
|
||||||
|
*
|
||||||
|
* CollectionTypes
|
||||||
|
* readonly Unknown: "unknown";
|
||||||
|
readonly Movies: "movies";
|
||||||
|
readonly Tvshows: "tvshows";
|
||||||
|
readonly Trailers: "trailers";
|
||||||
|
readonly Homevideos: "homevideos";
|
||||||
|
readonly Boxsets: "boxsets";
|
||||||
|
readonly Books: "books";
|
||||||
|
readonly Photos: "photos";
|
||||||
|
readonly Livetv: "livetv";
|
||||||
|
readonly Playlists: "playlists";
|
||||||
|
readonly Folders: "folders";
|
||||||
|
*/
|
||||||
|
export const colletionTypeToItemType = (
|
||||||
|
collectionType?: CollectionType | null,
|
||||||
|
): BaseItemKind | undefined => {
|
||||||
|
if (!collectionType) return undefined;
|
||||||
|
|
||||||
|
switch (collectionType) {
|
||||||
|
case CollectionType.Movies:
|
||||||
|
return BaseItemKind.Movie;
|
||||||
|
case CollectionType.Tvshows:
|
||||||
|
return BaseItemKind.Series;
|
||||||
|
case CollectionType.Homevideos:
|
||||||
|
return BaseItemKind.Video;
|
||||||
|
case CollectionType.Books:
|
||||||
|
return BaseItemKind.Book;
|
||||||
|
case CollectionType.Playlists:
|
||||||
|
return BaseItemKind.Playlist;
|
||||||
|
case CollectionType.Folders:
|
||||||
|
return BaseItemKind.Folder;
|
||||||
|
case CollectionType.Photos:
|
||||||
|
return BaseItemKind.Photo;
|
||||||
|
case CollectionType.Trailers:
|
||||||
|
return BaseItemKind.Trailer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
56
utils/hls/parseM3U8ForSubtitles.ts
Normal file
56
utils/hls/parseM3U8ForSubtitles.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export interface SubtitleTrack {
|
||||||
|
index: number;
|
||||||
|
name: string;
|
||||||
|
uri: string;
|
||||||
|
language: string;
|
||||||
|
default: boolean;
|
||||||
|
forced: boolean;
|
||||||
|
autoSelect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseM3U8ForSubtitles(
|
||||||
|
url: string,
|
||||||
|
): Promise<SubtitleTrack[]> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, { responseType: "text" });
|
||||||
|
const lines = response.data.split(/\r?\n/);
|
||||||
|
const subtitleTracks: SubtitleTrack[] = [];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
lines.forEach((line: string) => {
|
||||||
|
if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) {
|
||||||
|
const attributes = parseAttributes(line);
|
||||||
|
const track: SubtitleTrack = {
|
||||||
|
index: index++,
|
||||||
|
name: attributes.NAME || "",
|
||||||
|
uri: attributes.URI || "",
|
||||||
|
language: attributes.LANGUAGE || "",
|
||||||
|
default: attributes.DEFAULT === "YES",
|
||||||
|
forced: attributes.FORCED === "YES",
|
||||||
|
autoSelect: attributes.AUTOSELECT === "YES",
|
||||||
|
};
|
||||||
|
subtitleTracks.push(track);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return subtitleTracks;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch or parse the M3U8 file:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAttributes(line: string): { [key: string]: string } {
|
||||||
|
const attributes: { [key: string]: string } = {};
|
||||||
|
const regex = /([A-Z-]+)=(?:"([^"]*)"|([^,]*))/g;
|
||||||
|
|
||||||
|
for (const match of line.matchAll(regex)) {
|
||||||
|
const key = match[1];
|
||||||
|
const value = match[2] ?? match[3]; // quoted or unquoted
|
||||||
|
attributes[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
56
utils/jellyfin/session/capabilities.ts
Normal file
56
utils/jellyfin/session/capabilities.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import type { Settings } from "../../atoms/settings";
|
||||||
|
import { generateDeviceProfile } from "../../profiles/native";
|
||||||
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
|
||||||
|
interface PostCapabilitiesParams {
|
||||||
|
api: Api | null | undefined;
|
||||||
|
itemId: string | null | undefined;
|
||||||
|
sessionId: string | null | undefined;
|
||||||
|
deviceProfile: Settings["deviceProfile"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a media item as not played for a specific user.
|
||||||
|
*
|
||||||
|
* @param params - The parameters for marking an item as not played
|
||||||
|
* @returns A promise that resolves to true if the operation was successful, false otherwise
|
||||||
|
*/
|
||||||
|
export const postCapabilities = async ({
|
||||||
|
api,
|
||||||
|
itemId,
|
||||||
|
sessionId,
|
||||||
|
}: PostCapabilitiesParams): Promise<AxiosResponse> => {
|
||||||
|
if (!api || !itemId || !sessionId) {
|
||||||
|
throw new Error("Missing parameters for marking item as not played");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const d = api.axiosInstance.post(
|
||||||
|
`${api.basePath}/Sessions/Capabilities/Full`,
|
||||||
|
{
|
||||||
|
playableMediaTypes: ["Audio", "Video"],
|
||||||
|
supportedCommands: [
|
||||||
|
"PlayState",
|
||||||
|
"Play",
|
||||||
|
"ToggleFullscreen",
|
||||||
|
"DisplayMessage",
|
||||||
|
"Mute",
|
||||||
|
"Unmute",
|
||||||
|
"SetVolume",
|
||||||
|
"ToggleMute",
|
||||||
|
],
|
||||||
|
supportsMediaControl: true,
|
||||||
|
id: sessionId,
|
||||||
|
DeviceProfile: generateDeviceProfile(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return d;
|
||||||
|
} catch (_error) {
|
||||||
|
throw new Error("Failed to mark as not played");
|
||||||
|
}
|
||||||
|
};
|
||||||
44
utils/jellyfin/tvshows/nextUp.ts
Normal file
44
utils/jellyfin/tvshows/nextUp.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
|
||||||
|
interface NextUpParams {
|
||||||
|
itemId?: string | null;
|
||||||
|
userId?: string | null;
|
||||||
|
api?: Api | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the next up episodes for a series or all series for a user.
|
||||||
|
*
|
||||||
|
* @param params - The parameters for fetching next up episodes
|
||||||
|
* @returns A promise that resolves to an array of BaseItemDto representing the next up episodes
|
||||||
|
*/
|
||||||
|
export const nextUp = async ({
|
||||||
|
itemId,
|
||||||
|
userId,
|
||||||
|
api,
|
||||||
|
}: NextUpParams): Promise<BaseItemDto[]> => {
|
||||||
|
if (!userId || !api) {
|
||||||
|
console.error("Invalid parameters for nextUp: missing userId or api");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.axiosInstance.get<{ Items: BaseItemDto[] }>(
|
||||||
|
`${api.basePath}/Shows/NextUp`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
SeriesId: itemId || undefined,
|
||||||
|
UserId: userId,
|
||||||
|
Fields: "MediaSourceCount",
|
||||||
|
},
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data.Items;
|
||||||
|
} catch (_error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
34
utils/jellyfin/user-library/getItemById.ts
Normal file
34
utils/jellyfin/user-library/getItemById.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves an item by its ID from the API.
|
||||||
|
*
|
||||||
|
* @param api - The Jellyfin API instance.
|
||||||
|
* @param itemId - The ID of the item to retrieve.
|
||||||
|
* @returns The item object or undefined if no item matches the ID.
|
||||||
|
*/
|
||||||
|
export const getItemById = async (
|
||||||
|
api?: Api | null | undefined,
|
||||||
|
itemId?: string | null | undefined,
|
||||||
|
): Promise<BaseItemDto | undefined> => {
|
||||||
|
if (!api || !itemId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const itemData = await getUserLibraryApi(api).getItem({ itemId });
|
||||||
|
|
||||||
|
const item = itemData.data;
|
||||||
|
if (!item) {
|
||||||
|
console.error("No items found with the specified ID:", itemId);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to retrieve the item:", error);
|
||||||
|
throw new Error(`Failed to retrieve the item due to an error: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -72,6 +72,21 @@ export const readFromLog = (): LogEntry[] => {
|
|||||||
return logs ? JSON.parse(logs) : [];
|
return logs ? JSON.parse(logs) : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const clearLogs = () => {
|
||||||
|
storage.remove("logs");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dumpDownloadDiagnostics = (extra: any = {}) => {
|
||||||
|
const diagnostics = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
processes: extra?.processes || [],
|
||||||
|
nativeTasks: extra?.nativeTasks || [],
|
||||||
|
focusedProcess: extra?.focusedProcess || null,
|
||||||
|
};
|
||||||
|
writeDebugLog("Download diagnostics", diagnostics);
|
||||||
|
return diagnostics;
|
||||||
|
};
|
||||||
|
|
||||||
export function useLog() {
|
export function useLog() {
|
||||||
const context = useContext(LogContext);
|
const context = useContext(LogContext);
|
||||||
if (context === null) {
|
if (context === null) {
|
||||||
|
|||||||
5
utils/secondsToTicks.ts
Normal file
5
utils/secondsToTicks.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// seconds to ticks util
|
||||||
|
|
||||||
|
export function secondsToTicks(seconds: number): number {
|
||||||
|
return seconds * 10000000;
|
||||||
|
}
|
||||||
@@ -203,6 +203,27 @@ export async function hasAccountCredential(
|
|||||||
return stored !== null;
|
return stored !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all credentials for all accounts on all servers.
|
||||||
|
*/
|
||||||
|
export async function clearAllCredentials(): Promise<void> {
|
||||||
|
const previousServers = getPreviousServers();
|
||||||
|
|
||||||
|
for (const server of previousServers) {
|
||||||
|
for (const account of server.accounts) {
|
||||||
|
const key = credentialKey(server.address, account.userId);
|
||||||
|
await SecureStore.deleteItemAsync(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all accounts from servers
|
||||||
|
const clearedServers = previousServers.map((server) => ({
|
||||||
|
...server,
|
||||||
|
accounts: [],
|
||||||
|
}));
|
||||||
|
storage.set("previousServers", JSON.stringify(clearedServers));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add or update an account in a server's accounts list.
|
* Add or update an account in a server's accounts list.
|
||||||
*/
|
*/
|
||||||
|
|||||||
94
utils/version.ts
Normal file
94
utils/version.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import * as Application from "expo-application";
|
||||||
|
import Constants from "expo-constants";
|
||||||
|
|
||||||
|
/** Raw marketing version (app.json `version`), e.g. "0.54.1". Exposed so the Jellyfin
|
||||||
|
* clientInfo auto-tracks the app version instead of a hardcoded string. */
|
||||||
|
export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown";
|
||||||
|
|
||||||
|
/** Build metadata injected at build time by `app.config.js` into `extra.build`. */
|
||||||
|
export interface BuildMeta {
|
||||||
|
commit?: string | null;
|
||||||
|
branch?: string | null;
|
||||||
|
profile?: string | null;
|
||||||
|
runNumber?: string | null;
|
||||||
|
builtAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionInfo {
|
||||||
|
/** Marketing version (CFBundleShortVersionString / android versionName), e.g. "0.54.1". */
|
||||||
|
version: string | null;
|
||||||
|
/** Build number (CFBundleVersion / versionCode), e.g. "42". */
|
||||||
|
build: string | null;
|
||||||
|
/** Short git commit the build was made from, e.g. "a1b2c3d". */
|
||||||
|
commit: string | null;
|
||||||
|
/** Git branch the build was made from, e.g. "develop". */
|
||||||
|
branch: string | null;
|
||||||
|
/** EAS build profile, e.g. "production", "ci", "preview", or null for local. */
|
||||||
|
profile: string | null;
|
||||||
|
/** GitHub Actions run number the build came from, e.g. "2098". Null outside CI. */
|
||||||
|
runNumber: string | null;
|
||||||
|
isDev: boolean;
|
||||||
|
isProduction: boolean;
|
||||||
|
/** Graduated label for the Settings "App version" row (see tiering below). */
|
||||||
|
display: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a graduated version string for Settings.
|
||||||
|
*
|
||||||
|
* Tiering (most → least detailed):
|
||||||
|
* - dev / local build → `version · branch · commit` (full context for debugging)
|
||||||
|
* - develop / CI / preview → `version · commit · #run` (pin the exact source; the
|
||||||
|
* Actions run number maps the build to its run — artifacts + logs — without
|
||||||
|
* Expo access)
|
||||||
|
* - production (store / TestFlight) → `version` (build number intentionally
|
||||||
|
* not shown: TestFlight already displays it to testers, and the commit pins the
|
||||||
|
* binary better)
|
||||||
|
*/
|
||||||
|
export function getVersionInfo(): VersionInfo {
|
||||||
|
// Read native/config values defensively — a version string must never crash Settings
|
||||||
|
// (e.g. a dev build whose native expo-constants is out of sync with the JS).
|
||||||
|
const read = <T>(fn: () => T): T | null => {
|
||||||
|
try {
|
||||||
|
return fn() ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const version = read(() => Application.nativeApplicationVersion);
|
||||||
|
const build = read(() => Application.nativeBuildVersion);
|
||||||
|
const meta = (read(() => Constants.expoConfig?.extra?.build) ??
|
||||||
|
{}) as BuildMeta;
|
||||||
|
const commit = meta.commit ?? null;
|
||||||
|
const branch = meta.branch ?? null;
|
||||||
|
const profile = meta.profile ?? null;
|
||||||
|
const runNumber = meta.runNumber ?? null;
|
||||||
|
const isDev = __DEV__ === true;
|
||||||
|
const isProduction =
|
||||||
|
typeof profile === "string" && profile.startsWith("production");
|
||||||
|
|
||||||
|
let display: string;
|
||||||
|
if (isDev) {
|
||||||
|
display = [version ?? "dev", branch, commit].filter(Boolean).join(" · ");
|
||||||
|
} else if (isProduction) {
|
||||||
|
display = version ?? build ?? "N/A";
|
||||||
|
} else {
|
||||||
|
display =
|
||||||
|
[version, commit, runNumber && `#${runNumber}`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ") || "N/A";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
build,
|
||||||
|
commit,
|
||||||
|
branch,
|
||||||
|
profile,
|
||||||
|
runNumber,
|
||||||
|
isDev,
|
||||||
|
isProduction,
|
||||||
|
display,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user