diff --git a/.gitignore b/.gitignore
index aedc9ceb..4c3672cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,7 @@ build-*
*.mp4
build-*
Streamyfin.app
+package-lock.json
/ios
/android
diff --git a/app.json b/app.json
index f3f24a2b..d4e5e08f 100644
--- a/app.json
+++ b/app.json
@@ -71,6 +71,13 @@
}
}
],
+ [
+ "./plugins/withAndroidMainActivityAttributes",
+ {
+ "com.reactnative.googlecast.RNGCExpandedControllerActivity": true
+ }
+ ],
+ ["./plugins/withExpandedController.js"],
[
"expo-build-properties",
{
diff --git a/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx
index 311b3805..0b34914f 100644
--- a/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx
@@ -10,8 +10,10 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
genreFilterAtom,
sortByAtom,
+ SortByOption,
sortOptions,
sortOrderAtom,
+ SortOrderOption,
sortOrderOptions,
tagsFilterAtom,
yearFilterAtom,
@@ -19,6 +21,7 @@ import {
import {
BaseItemDto,
BaseItemDtoQueryResult,
+ ItemSortBy,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getFilterApi,
@@ -58,21 +61,6 @@ const page: React.FC = () => {
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
- useLayoutEffect(() => {
- setSortBy([
- {
- key: "PremiereDate",
- value: "Premiere Date",
- },
- ]);
- setSortOrder([
- {
- key: "Ascending",
- value: "Ascending",
- },
- ]);
- }, []);
-
const { data: collection } = useQuery({
queryKey: ["collection", collectionId],
queryFn: async () => {
@@ -90,6 +78,18 @@ const page: React.FC = () => {
useEffect(() => {
navigation.setOptions({ title: collection?.Name || "" });
+ setSortOrder([SortOrderOption.Ascending]);
+
+ if (!collection) return;
+
+ // Convert the DisplayOrder to SortByOption
+ const displayOrder = collection.DisplayOrder as ItemSortBy;
+ const sortByOption = displayOrder
+ ? SortByOption[displayOrder as keyof typeof SortByOption] ||
+ SortByOption.PremiereDate
+ : SortByOption.PremiereDate;
+
+ setSortBy([sortByOption]);
}, [navigation, collection]);
const fetchItems = useCallback(
@@ -105,8 +105,9 @@ const page: React.FC = () => {
parentId: collectionId,
limit: 18,
startIndex: pageParam,
- sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
- sortOrder: [sortOrder[0].key],
+ // Set one ordering at a time. As collections do not work with correctly with multiple.
+ sortBy: [sortBy[0]],
+ sortOrder: [sortOrder[0]],
fields: [
"ItemCounts",
"PrimaryImageAspectRatio",
@@ -219,6 +220,13 @@ const page: React.FC = () => {
paddingVertical: 16,
flexDirection: "row",
}}
+ extraData={[
+ selectedGenres,
+ selectedYears,
+ selectedTags,
+ sortBy,
+ sortOrder,
+ ]}
data={[
{
key: "reset",
@@ -310,13 +318,15 @@ const page: React.FC = () => {
className="mr-1"
collectionId={collectionId}
queryKey="sortBy"
- queryFn={async () => sortOptions}
+ queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
title="Sort By"
- renderItemLabel={(item) => item.value}
+ renderItemLabel={(item) =>
+ sortOptions.find((i) => i.key === item)?.value || ""
+ }
searchFilter={(item, search) =>
- item.value.toLowerCase().includes(search.toLowerCase())
+ item.toLowerCase().includes(search.toLowerCase())
}
/>
),
@@ -328,13 +338,15 @@ const page: React.FC = () => {
className="mr-1"
collectionId={collectionId}
queryKey="sortOrder"
- queryFn={async () => sortOrderOptions}
+ queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
title="Sort Order"
- renderItemLabel={(item) => item.value}
+ renderItemLabel={(item) =>
+ sortOrderOptions.find((i) => i.key === item)?.value || ""
+ }
searchFilter={(item, search) =>
- item.value.toLowerCase().includes(search.toLowerCase())
+ item.toLowerCase().includes(search.toLowerCase())
}
/>
),
@@ -372,6 +384,13 @@ const page: React.FC = () => {
No results
}
+ extraData={[
+ selectedGenres,
+ selectedYears,
+ selectedTags,
+ sortBy,
+ sortOrder,
+ ]}
contentInsetAdjustmentBehavior="automatic"
data={flatData}
renderItem={renderItem}
diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
index 4932c86d..298bd3d3 100644
--- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
+++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
@@ -9,25 +9,23 @@ import React, {
useMemo,
useState,
} from "react";
-import {
- FlatList,
- RefreshControl,
- useWindowDimensions,
- View,
-} from "react-native";
+import { FlatList, useWindowDimensions, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
+import { Loader } from "@/components/Loader";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
genreFilterAtom,
sortByAtom,
+ SortByOption,
sortOptions,
sortOrderAtom,
+ SortOrderOption,
sortOrderOptions,
tagsFilterAtom,
yearFilterAtom,
@@ -35,7 +33,6 @@ import {
import {
BaseItemDto,
BaseItemDtoQueryResult,
- BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getFilterApi,
@@ -43,7 +40,6 @@ import {
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
-import { Loader } from "@/components/Loader";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { orientationAtom } from "@/utils/atoms/orientation";
import { ItemPoster } from "@/components/posters/ItemPoster";
@@ -56,7 +52,6 @@ const Page = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
- const navigation = useNavigation();
const { width: screenWidth } = useWindowDimensions();
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
@@ -76,18 +71,8 @@ const Page = () => {
}, [screenWidth, orientation]);
useLayoutEffect(() => {
- setSortBy([
- {
- key: "SortName",
- value: "Name",
- },
- ]);
- setSortOrder([
- {
- key: "Ascending",
- value: "Ascending",
- },
- ]);
+ setSortBy([SortByOption.SortName]);
+ setSortOrder([SortOrderOption.Ascending]);
}, []);
const { data: library, isLoading: isLibraryLoading } = useQuery({
@@ -117,8 +102,8 @@ const Page = () => {
parentId: libraryId,
limit: 36,
startIndex: pageParam,
- sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
- sortOrder: [sortOrder[0].key],
+ sortBy: [sortBy[0], "SortName", "ProductionYear"],
+ sortOrder: [sortOrder[0]],
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
recursive: false,
imageTypeLimit: 1,
@@ -323,13 +308,15 @@ const Page = () => {
className="mr-1"
collectionId={libraryId}
queryKey="sortBy"
- queryFn={async () => sortOptions}
+ queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
title="Sort By"
- renderItemLabel={(item) => item.value}
+ renderItemLabel={(item) =>
+ sortOptions.find((i) => i.key === item)?.value || ""
+ }
searchFilter={(item, search) =>
- item.value.toLowerCase().includes(search.toLowerCase())
+ item.toLowerCase().includes(search.toLowerCase())
}
/>
),
@@ -341,13 +328,15 @@ const Page = () => {
className="mr-1"
collectionId={libraryId}
queryKey="sortOrder"
- queryFn={async () => sortOrderOptions}
+ queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
title="Sort Order"
- renderItemLabel={(item) => item.value}
+ renderItemLabel={(item) =>
+ sortOrderOptions.find((i) => i.key === item)?.value || ""
+ }
searchFilter={(item, search) =>
- item.value.toLowerCase().includes(search.toLowerCase())
+ item.toLowerCase().includes(search.toLowerCase())
}
/>
),
diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx
index 0b8c9cce..ff88767c 100644
--- a/components/Chromecast.tsx
+++ b/components/Chromecast.tsx
@@ -1,10 +1,12 @@
+import { Feather } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import React, { useEffect } from "react";
-import { Platform, View, ViewProps } from "react-native";
+import { Platform, TouchableOpacity, ViewProps } from "react-native";
import GoogleCast, {
- CastButton,
+ CastContext,
useCastDevice,
useDevices,
+ useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
@@ -25,6 +27,7 @@ export const Chromecast: React.FC = ({
const devices = useDevices();
const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager();
+ const mediaStatus = useMediaStatus();
useEffect(() => {
(async () => {
@@ -38,31 +41,47 @@ export const Chromecast: React.FC = ({
if (background === "transparent")
return (
- {
+ if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
+ else CastContext.showCastDialog();
+ }}
className="rounded-full h-10 w-10 flex items-center justify-center b"
{...props}
>
-
-
+
+
);
if (Platform.OS === "android")
return (
- {
+ if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
+ else CastContext.showCastDialog();
+ }}
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
{...props}
>
-
-
+
+
);
return (
- {
+ if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
+ else CastContext.showCastDialog();
+ }}
{...props}
>
-
-
+
+
+
+
);
};
diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx
index 4fb1dbef..595afd25 100644
--- a/components/PlayButton.tsx
+++ b/components/PlayButton.tsx
@@ -9,6 +9,7 @@ import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import CastContext, {
PlayServicesState,
+ useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import Animated, {
@@ -22,6 +23,10 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { Button } from "./Button";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
+import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
interface Props extends React.ComponentProps {
item?: BaseItemDto | null;
@@ -33,11 +38,12 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC = ({ item, url, ...props }) => {
const { showActionSheetWithOptions } = useActionSheet();
- const { setCurrentlyPlayingState } = usePlayback();
-
const client = useRemoteMediaClient();
+ const { setCurrentlyPlayingState } = usePlayback();
+ const mediaStatus = useMediaStatus();
const [colorAtom] = useAtom(itemThemeColorAtom);
+ const [api] = useAtom(apiAtom);
const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
@@ -63,24 +69,88 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => {
cancelButtonIndex,
},
async (selectedIndex: number | undefined) => {
+ if (!api) return;
+ const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
+ const isOpeningCurrentlyPlayingMedia =
+ currentTitle && currentTitle === item?.Name;
+
switch (selectedIndex) {
case 0:
await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
- client.loadMedia({
- mediaInfo: {
- contentUrl: url,
- contentType: "video/mp4",
- metadata: {
- type: item.Type === "Episode" ? "tvShow" : "movie",
- title: item.Name || "",
- subtitle: item.Overview || "",
+ // If we're opening a currently playing item, don't restart the media.
+ // Instead just open controls.
+ if (isOpeningCurrentlyPlayingMedia) {
+ CastContext.showExpandedControls();
+ return;
+ }
+ client
+ .loadMedia({
+ mediaInfo: {
+ contentUrl: url,
+ contentType: "video/mp4",
+ metadata:
+ item.Type === "Episode"
+ ? {
+ type: "tvShow",
+ title: item.Name || "",
+ episodeNumber: item.IndexNumber || 0,
+ seasonNumber: item.ParentIndexNumber || 0,
+ seriesTitle: item.SeriesName || "",
+ images: [
+ {
+ url: getParentBackdropImageUrl({
+ api,
+ item,
+ quality: 90,
+ width: 2000,
+ })!,
+ },
+ ],
+ }
+ : item.Type === "Movie"
+ ? {
+ type: "movie",
+ title: item.Name || "",
+ subtitle: item.Overview || "",
+ images: [
+ {
+ url: getPrimaryImageUrl({
+ api,
+ item,
+ quality: 90,
+ width: 2000,
+ })!,
+ },
+ ],
+ }
+ : {
+ type: "generic",
+ title: item.Name || "",
+ subtitle: item.Overview || "",
+ images: [
+ {
+ url: getPrimaryImageUrl({
+ api,
+ item,
+ quality: 90,
+ width: 2000,
+ })!,
+ },
+ ],
+ },
},
- },
- startTime: 0,
- });
+ startTime: 0,
+ })
+ .then(() => {
+ // state is already set when reopening current media, so skip it here.
+ if (isOpeningCurrentlyPlayingMedia) {
+ return;
+ }
+ CastContext.showExpandedControls();
+ });
}
});
break;
diff --git a/components/filters/FilterButton.tsx b/components/filters/FilterButton.tsx
index e50e4010..6d976b26 100644
--- a/components/filters/FilterButton.tsx
+++ b/components/filters/FilterButton.tsx
@@ -23,7 +23,7 @@ export const FilterButton = ({
queryFn,
queryKey,
set,
- values,
+ values, // selected values
title,
renderItemLabel,
searchFilter,
diff --git a/components/filters/FilterSheet.tsx b/components/filters/FilterSheet.tsx
index 7f163b72..fe6d9f6a 100644
--- a/components/filters/FilterSheet.tsx
+++ b/components/filters/FilterSheet.tsx
@@ -186,7 +186,7 @@ export const FilterSheet = ({
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
>
{renderItemLabel(item)}
- {values.includes(item) ? (
+ {values.some((i) => i === item) ? (
) : (
diff --git a/plugins/withAndroidMainActivityAttributes.js b/plugins/withAndroidMainActivityAttributes.js
new file mode 100644
index 00000000..c5764408
--- /dev/null
+++ b/plugins/withAndroidMainActivityAttributes.js
@@ -0,0 +1,42 @@
+const { withAndroidManifest } = require("@expo/config-plugins");
+
+function addAttributesToMainActivity(androidManifest, attributes) {
+ const { manifest } = androidManifest;
+
+ if (!Array.isArray(manifest["application"])) {
+ console.warn("withAndroidMainActivityAttributes: No application array in manifest?");
+ return androidManifest;
+ }
+
+ const application = manifest["application"].find(
+ (item) => item.$["android:name"] === ".MainApplication"
+ );
+ if (!application) {
+ console.warn("withAndroidMainActivityAttributes: No .MainApplication?");
+ return androidManifest;
+ }
+
+ if (!Array.isArray(application["activity"])) {
+ console.warn("withAndroidMainActivityAttributes: No activity array in .MainApplication?");
+ return androidManifest;
+ }
+
+ const activity = application["activity"].find(
+ (item) => item.$["android:name"] === ".MainActivity"
+ );
+ if (!activity) {
+ console.warn("withAndroidMainActivityAttributes: No .MainActivity?");
+ return androidManifest;
+ }
+
+ activity.$ = { ...activity.$, ...attributes };
+
+ return androidManifest;
+}
+
+module.exports = function withAndroidMainActivityAttributes(config, attributes) {
+ return withAndroidManifest(config, (config) => {
+ config.modResults = addAttributesToMainActivity(config.modResults, attributes);
+ return config;
+ });
+};
diff --git a/plugins/withExpandedController.js b/plugins/withExpandedController.js
new file mode 100644
index 00000000..9ea30dcd
--- /dev/null
+++ b/plugins/withExpandedController.js
@@ -0,0 +1,20 @@
+const { withAppDelegate } = require("@expo/config-plugins");
+
+const withExpandedController = (config) => {
+ return withAppDelegate(config, async (config) => {
+ const contents = config.modResults.contents;
+
+ // Looking for the initialProps string inside didFinishLaunchingWithOptions,
+ // and injecting expanded controller config.
+ // Should be updated once there is an expo config option - see https://github.com/react-native-google-cast/react-native-google-cast/discussions/537
+ const injectionIndex = contents.indexOf("self.initialProps = @{};");
+ config.modResults.contents =
+ contents.substring(0, injectionIndex) +
+ `\n [GCKCastContext sharedInstance].useDefaultExpandedMediaControls = true; \n` +
+ contents.substring(injectionIndex);
+
+ return config;
+ });
+};
+
+module.exports = withExpandedController;
diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx
index 03b6363f..14fcf123 100644
--- a/providers/PlaybackProvider.tsx
+++ b/providers/PlaybackProvider.tsx
@@ -224,7 +224,9 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
useEffect(() => {
if (!deviceId || !api?.accessToken) return;
- const url = `wss://${api?.basePath
+ const protocol = api?.basePath.includes("https") ? "wss" : "ws";
+
+ const url = `${protocol}://${api?.basePath
.replace("https://", "")
.replace("http://", "")}/socket?api_key=${
api?.accessToken
diff --git a/utils/atoms/filters.ts b/utils/atoms/filters.ts
index 7324f569..f49b17d4 100644
--- a/utils/atoms/filters.ts
+++ b/utils/atoms/filters.ts
@@ -1,50 +1,67 @@
-import {
- ItemFilter,
- ItemSortBy,
- NameGuidPair,
- SortOrder,
-} from "@jellyfin/sdk/lib/generated-client/models";
-import { atom, useAtom } from "jotai";
+import { atom } from "jotai";
+
+export enum SortByOption {
+ Default = "Default",
+ SortName = "SortName",
+ CommunityRating = "CommunityRating",
+ CriticRating = "CriticRating",
+ DateCreated = "DateCreated",
+ DatePlayed = "DatePlayed",
+ PlayCount = "PlayCount",
+ ProductionYear = "ProductionYear",
+ Runtime = "Runtime",
+ OfficialRating = "OfficialRating",
+ PremiereDate = "PremiereDate",
+ StartDate = "StartDate",
+ IsUnplayed = "IsUnplayed",
+ IsPlayed = "IsPlayed",
+ AirTime = "AirTime",
+ Studio = "Studio",
+ IsFavoriteOrLiked = "IsFavoriteOrLiked",
+ Random = "Random",
+}
+
+export enum SortOrderOption {
+ Ascending = "Ascending",
+ Descending = "Descending",
+}
export const sortOptions: {
- key: ItemSortBy;
+ key: SortByOption;
value: string;
}[] = [
- { key: "SortName", value: "Name" },
- { key: "CommunityRating", value: "Community Rating" },
- { key: "CriticRating", value: "Critics Rating" },
- { key: "DateCreated", value: "Date Added" },
- // Only works for shows (last episode added) keeping for future ref.
- // { key: "DateLastContentAdded", value: "Content Added" },
- { key: "DatePlayed", value: "Date Played" },
- { key: "PlayCount", value: "Play Count" },
- { key: "ProductionYear", value: "Production Year" },
- { key: "Runtime", value: "Runtime" },
- { key: "OfficialRating", value: "Official Rating" },
- { key: "PremiereDate", value: "Premiere Date" },
- { key: "StartDate", value: "Start Date" },
- { key: "IsUnplayed", value: "Is Unplayed" },
- { key: "IsPlayed", value: "Is Played" },
- // Broken in JF
- // { key: "VideoBitRate", value: "Video Bit Rate" },
- { key: "AirTime", value: "Air Time" },
- { key: "Studio", value: "Studio" },
- { key: "IsFavoriteOrLiked", value: "Is Favorite Or Liked" },
- { key: "Random", value: "Random" },
+ { key: SortByOption.Default, value: "Default" },
+ { key: SortByOption.SortName, value: "Name" },
+ { key: SortByOption.CommunityRating, value: "Community Rating" },
+ { key: SortByOption.CriticRating, value: "Critics Rating" },
+ { key: SortByOption.DateCreated, value: "Date Added" },
+ { key: SortByOption.DatePlayed, value: "Date Played" },
+ { key: SortByOption.PlayCount, value: "Play Count" },
+ { key: SortByOption.ProductionYear, value: "Production Year" },
+ { key: SortByOption.Runtime, value: "Runtime" },
+ { key: SortByOption.OfficialRating, value: "Official Rating" },
+ { key: SortByOption.PremiereDate, value: "Premiere Date" },
+ { key: SortByOption.StartDate, value: "Start Date" },
+ { key: SortByOption.IsUnplayed, value: "Is Unplayed" },
+ { key: SortByOption.IsPlayed, value: "Is Played" },
+ { key: SortByOption.AirTime, value: "Air Time" },
+ { key: SortByOption.Studio, value: "Studio" },
+ { key: SortByOption.IsFavoriteOrLiked, value: "Is Favorite Or Liked" },
+ { key: SortByOption.Random, value: "Random" },
];
export const sortOrderOptions: {
- key: SortOrder;
+ key: SortOrderOption;
value: string;
}[] = [
- { key: "Ascending", value: "Ascending" },
- { key: "Descending", value: "Descending" },
+ { key: SortOrderOption.Ascending, value: "Ascending" },
+ { key: SortOrderOption.Descending, value: "Descending" },
];
export const genreFilterAtom = atom([]);
export const tagsFilterAtom = atom([]);
export const yearFilterAtom = atom([]);
-export const sortByAtom = atom<[typeof sortOptions][number]>([sortOptions[0]]);
-export const sortOrderAtom = atom<[typeof sortOrderOptions][number]>([
- sortOrderOptions[0],
+export const sortByAtom = atom([SortByOption.Default]);
+export const sortOrderAtom = atom([
+ SortOrderOption.Ascending,
]);