From e9783d293dce4397d3e131b1996788799d7e6e18 Mon Sep 17 00:00:00 2001 From: jakequade Date: Sat, 24 Aug 2024 14:37:49 +1000 Subject: [PATCH 01/15] extended cast controls on android --- app.json | 6 +++ components/PlayButton.tsx | 21 +++++++++- plugins/withAndroidMainActivityAttributes.js | 42 ++++++++++++++++++++ providers/PlaybackProvider.tsx | 2 +- 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 plugins/withAndroidMainActivityAttributes.js diff --git a/app.json b/app.json index 3141a8ad..ad62a054 100644 --- a/app.json +++ b/app.json @@ -68,6 +68,12 @@ } } ], + [ + "./plugins/withAndroidMainActivityAttributes", + { + "com.reactnative.googlecast.RNGCExpandedControllerActivity": true + } + ], [ "expo-build-properties", { diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 2a539736..32b935b5 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -9,6 +9,7 @@ import CastContext, { useRemoteMediaClient, } from "react-native-google-cast"; import { Button } from "./Button"; +import { isCancel } from "axios"; interface Props extends React.ComponentProps { item?: BaseItemDto | null; @@ -18,7 +19,7 @@ interface Props extends React.ComponentProps { export const PlayButton: React.FC = ({ item, url, ...props }) => { const { showActionSheetWithOptions } = useActionSheet(); const client = useRemoteMediaClient(); - const { setCurrentlyPlayingState } = usePlayback(); + const { setCurrentlyPlayingState, isPlaying, currentlyPlaying } = usePlayback(); const onPress = async () => { if (!url || !item) return; @@ -37,12 +38,22 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { cancelButtonIndex, }, async (selectedIndex: number | undefined) => { + const isOpeningCurrentlyPlayingMedia = isPlaying + && currentlyPlaying?.item?.Name + && currentlyPlaying?.item?.Name === item?.Name switch (selectedIndex) { case 0: await CastContext.getPlayServicesState().then((state) => { if (state && state !== PlayServicesState.SUCCESS) CastContext.showPlayServicesErrorDialog(state); else { + // If we're opening a currently playing item, don't restart the media. + // Instead just open controls + console.log({ isOpeningCurrentlyPlayingMedia, currentlyPlaying }) + if (isOpeningCurrentlyPlayingMedia) { + CastContext.showExpandedControls(); + return; + } client.loadMedia({ mediaInfo: { contentUrl: url, @@ -54,6 +65,14 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { }, }, startTime: 0, + }).then(() => { + if (isOpeningCurrentlyPlayingMedia) { + return + } + setCurrentlyPlayingState({ item, url }); + CastContext.showExpandedControls(); + }).catch(e => { + console.log({ e }) }); } }); diff --git a/plugins/withAndroidMainActivityAttributes.js b/plugins/withAndroidMainActivityAttributes.js new file mode 100644 index 00000000..d57b8c93 --- /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; + }); +}; \ No newline at end of file diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx index 1f2c3bdd..bd26807d 100644 --- a/providers/PlaybackProvider.tsx +++ b/providers/PlaybackProvider.tsx @@ -181,7 +181,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ useEffect(() => { if (!deviceId || !api?.accessToken) return; - const url = `wss://${api?.basePath + const url = `ws://${api?.basePath .replace("https://", "") .replace("http://", "")}/socket?api_key=${ api?.accessToken From fb6e3dc69042a1e132bbff1d97df8998b7480c9f Mon Sep 17 00:00:00 2001 From: jakequade Date: Sat, 24 Aug 2024 14:53:33 +1000 Subject: [PATCH 02/15] chromecast controls --- components/PlayButton.tsx | 18 +++++++++--------- plugins/withAndroidMainActivityAttributes.js | 2 +- providers/PlaybackProvider.tsx | 6 ++++-- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 32b935b5..dc52976b 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -7,6 +7,7 @@ import { View } from "react-native"; import CastContext, { PlayServicesState, useRemoteMediaClient, + useMediaStatus, } from "react-native-google-cast"; import { Button } from "./Button"; import { isCancel } from "axios"; @@ -19,7 +20,8 @@ interface Props extends React.ComponentProps { export const PlayButton: React.FC = ({ item, url, ...props }) => { const { showActionSheetWithOptions } = useActionSheet(); const client = useRemoteMediaClient(); - const { setCurrentlyPlayingState, isPlaying, currentlyPlaying } = usePlayback(); + const { setCurrentlyPlayingState } = usePlayback(); + const mediaStatus = useMediaStatus() const onPress = async () => { if (!url || !item) return; @@ -38,9 +40,9 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { cancelButtonIndex, }, async (selectedIndex: number | undefined) => { - const isOpeningCurrentlyPlayingMedia = isPlaying - && currentlyPlaying?.item?.Name - && currentlyPlaying?.item?.Name === item?.Name + const currentTitle = mediaStatus?.mediaInfo?.metadata?.title + const isOpeningCurrentlyPlayingMedia = currentTitle && currentTitle === item?.Name + switch (selectedIndex) { case 0: await CastContext.getPlayServicesState().then((state) => { @@ -48,8 +50,7 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { CastContext.showPlayServicesErrorDialog(state); else { // If we're opening a currently playing item, don't restart the media. - // Instead just open controls - console.log({ isOpeningCurrentlyPlayingMedia, currentlyPlaying }) + // Instead just open controls. if (isOpeningCurrentlyPlayingMedia) { CastContext.showExpandedControls(); return; @@ -66,14 +67,13 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { }, startTime: 0, }).then(() => { + // state is already set when reopening current media, so skip it here. if (isOpeningCurrentlyPlayingMedia) { return } setCurrentlyPlayingState({ item, url }); CastContext.showExpandedControls(); - }).catch(e => { - console.log({ e }) - }); + }) } }); break; diff --git a/plugins/withAndroidMainActivityAttributes.js b/plugins/withAndroidMainActivityAttributes.js index d57b8c93..c5764408 100644 --- a/plugins/withAndroidMainActivityAttributes.js +++ b/plugins/withAndroidMainActivityAttributes.js @@ -39,4 +39,4 @@ module.exports = function withAndroidMainActivityAttributes(config, attributes) config.modResults = addAttributesToMainActivity(config.modResults, attributes); return config; }); -}; \ No newline at end of file +}; diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx index bd26807d..c23b0137 100644 --- a/providers/PlaybackProvider.tsx +++ b/providers/PlaybackProvider.tsx @@ -181,13 +181,15 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ useEffect(() => { if (!deviceId || !api?.accessToken) return; - const url = `ws://${api?.basePath + const protocol = api?.basePath.includes('https') ? 'wss' : 'ws' + + const url = `${protocol}://${api?.basePath .replace("https://", "") .replace("http://", "")}/socket?api_key=${ api?.accessToken }&deviceId=${deviceId}`; - console.log("WS", url); + console.log(protocol, url); const newWebSocket = new WebSocket(url); From 688c343a352b7a267dbbafc8802907f0d25a6c24 Mon Sep 17 00:00:00 2001 From: jakequade Date: Sun, 25 Aug 2024 00:03:34 +1000 Subject: [PATCH 03/15] iOS support --- app.json | 1 + plugins/withExpandedController.js | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 plugins/withExpandedController.js diff --git a/app.json b/app.json index ad62a054..18082398 100644 --- a/app.json +++ b/app.json @@ -74,6 +74,7 @@ "com.reactnative.googlecast.RNGCExpandedControllerActivity": true } ], + ["./plugins/withExpandedController.js"], [ "expo-build-properties", { 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; From 30678813b4ed17e6d9e9f8cb9eac412f46dcd201 Mon Sep 17 00:00:00 2001 From: sarendsen Date: Fri, 30 Aug 2024 09:58:50 +0200 Subject: [PATCH 04/15] feat: Add Default option and use collection sorting as default --- .gitignore | 1 + .../collections/[collectionId].tsx | 31 +++++++++---------- utils/atoms/filters.ts | 1 + 3 files changed, 17 insertions(+), 16 deletions(-) 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/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx index b9a657be..3309eea3 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx @@ -17,6 +17,7 @@ import { import { BaseItemDto, BaseItemDtoQueryResult, + ItemSortBy, } from "@jellyfin/sdk/lib/generated-client/models"; import { getFilterApi, @@ -56,21 +57,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 () => { @@ -88,6 +74,18 @@ const page: React.FC = () => { useEffect(() => { navigation.setOptions({ title: collection?.Name || "" }); + setSortBy([ + { + key: collection?.DisplayOrder as ItemSortBy, + value: collection?.DisplayOrder ?? "Premiere Date", + }, + ]); + setSortOrder([ + { + key: "Ascending", + value: "Ascending", + }, + ]); }, [navigation, collection]); const fetchItems = useCallback( @@ -103,7 +101,8 @@ const page: React.FC = () => { parentId: collectionId, limit: 18, startIndex: pageParam, - sortBy: [sortBy[0].key, "SortName", "ProductionYear"], + // Set one ordering at a time. As collections do not work with correctly with multiple. + sortBy: [sortBy[0].key], sortOrder: [sortOrder[0].key], fields: [ "ItemCounts", diff --git a/utils/atoms/filters.ts b/utils/atoms/filters.ts index 7324f569..a1228be6 100644 --- a/utils/atoms/filters.ts +++ b/utils/atoms/filters.ts @@ -10,6 +10,7 @@ export const sortOptions: { key: ItemSortBy; value: string; }[] = [ + { key: "Default", value: "Default" }, { key: "SortName", value: "Name" }, { key: "CommunityRating", value: "Community Rating" }, { key: "CriticRating", value: "Critics Rating" }, From 32ac4ec62fdb299cdf2e7f8893a4e1db202ef77f Mon Sep 17 00:00:00 2001 From: sarendsen Date: Fri, 30 Aug 2024 10:04:02 +0200 Subject: [PATCH 05/15] fix: use PremiereDate as default if missing from collection --- .../(home,libraries,search)/collections/[collectionId].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx index 3309eea3..f5f342c1 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx @@ -76,7 +76,7 @@ const page: React.FC = () => { navigation.setOptions({ title: collection?.Name || "" }); setSortBy([ { - key: collection?.DisplayOrder as ItemSortBy, + key: (collection?.DisplayOrder as ItemSortBy) ?? "PremiereDate", value: collection?.DisplayOrder ?? "Premiere Date", }, ]); From 61cb205f935d28d85c9f34bcbae209e399fcfbda Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 30 Aug 2024 12:54:31 +0200 Subject: [PATCH 06/15] fix: refactor to use enums --- utils/atoms/filters.ts | 88 +++++++++++++++++++++++++----------------- 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/utils/atoms/filters.ts b/utils/atoms/filters.ts index a1228be6..f49b17d4 100644 --- a/utils/atoms/filters.ts +++ b/utils/atoms/filters.ts @@ -1,51 +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: "Default", value: "Default" }, - { 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, ]); From ef8bb3e717f4a094ec3e39fdb41d069d9bdf3de9 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 30 Aug 2024 12:54:38 +0200 Subject: [PATCH 07/15] chore --- components/filters/FilterButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From e29b3787b9bbee099cd4275aa3e3788bdc0b4f4f Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 30 Aug 2024 12:54:53 +0200 Subject: [PATCH 08/15] chore --- components/filters/FilterSheet.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) ? ( ) : ( From cca28d7e2153580779d1423980b795176f969f0a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 30 Aug 2024 12:55:28 +0200 Subject: [PATCH 09/15] fix: change to enums and only store key in filter state --- .../collections/[collectionId].tsx | 60 ++++++++++++------- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 47 ++++++--------- 2 files changed, 58 insertions(+), 49 deletions(-) diff --git a/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx index f5f342c1..b766391e 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx @@ -8,8 +8,10 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { genreFilterAtom, sortByAtom, + SortByOption, sortOptions, sortOrderAtom, + SortOrderOption, sortOrderOptions, tagsFilterAtom, yearFilterAtom, @@ -74,18 +76,18 @@ const page: React.FC = () => { useEffect(() => { navigation.setOptions({ title: collection?.Name || "" }); - setSortBy([ - { - key: (collection?.DisplayOrder as ItemSortBy) ?? "PremiereDate", - value: collection?.DisplayOrder ?? "Premiere Date", - }, - ]); - setSortOrder([ - { - key: "Ascending", - value: "Ascending", - }, - ]); + 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( @@ -102,8 +104,8 @@ const page: React.FC = () => { limit: 18, startIndex: pageParam, // Set one ordering at a time. As collections do not work with correctly with multiple. - sortBy: [sortBy[0].key], - sortOrder: [sortOrder[0].key], + sortBy: [sortBy[0]], + sortOrder: [sortOrder[0]], fields: [ "ItemCounts", "PrimaryImageAspectRatio", @@ -215,6 +217,13 @@ const page: React.FC = () => { paddingVertical: 16, flexDirection: "row", }} + extraData={[ + selectedGenres, + selectedYears, + selectedTags, + sortBy, + sortOrder, + ]} data={[ { key: "reset", @@ -306,13 +315,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()) } /> ), @@ -324,13 +335,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()) } /> ), @@ -368,6 +381,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 327d11b5..e0ccd048 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"; const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter); @@ -54,7 +50,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]); useLayoutEffect(() => { - setSortBy([ - { - key: "SortName", - value: "Name", - }, - ]); - setSortOrder([ - { - key: "Ascending", - value: "Ascending", - }, - ]); + setSortBy([SortByOption.SortName]); + setSortOrder([SortOrderOption.Ascending]); }, []); useEffect(() => { @@ -133,8 +118,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, @@ -338,13 +323,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()) } /> ), @@ -356,13 +343,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()) } /> ), From 080de162ec404241fb3d77449e5f862a4171407b Mon Sep 17 00:00:00 2001 From: jakequade Date: Sat, 24 Aug 2024 14:37:49 +1000 Subject: [PATCH 10/15] extended cast controls on android --- app.json | 6 +++ components/PlayButton.tsx | 22 +++++++++- plugins/withAndroidMainActivityAttributes.js | 42 ++++++++++++++++++++ providers/PlaybackProvider.tsx | 2 +- 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 plugins/withAndroidMainActivityAttributes.js diff --git a/app.json b/app.json index f3f24a2b..e6babcb7 100644 --- a/app.json +++ b/app.json @@ -71,6 +71,12 @@ } } ], + [ + "./plugins/withAndroidMainActivityAttributes", + { + "com.reactnative.googlecast.RNGCExpandedControllerActivity": true + } + ], [ "expo-build-properties", { diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 4fb1dbef..1f0c59fa 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -22,6 +22,7 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { Button } from "./Button"; +import { isCancel } from "axios"; interface Props extends React.ComponentProps { item?: BaseItemDto | null; @@ -33,9 +34,8 @@ const MIN_PLAYBACK_WIDTH = 15; export const PlayButton: React.FC = ({ item, url, ...props }) => { const { showActionSheetWithOptions } = useActionSheet(); - const { setCurrentlyPlayingState } = usePlayback(); - const client = useRemoteMediaClient(); + const { setCurrentlyPlayingState, isPlaying, currentlyPlaying } = usePlayback(); const [colorAtom] = useAtom(itemThemeColorAtom); @@ -63,12 +63,22 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { cancelButtonIndex, }, async (selectedIndex: number | undefined) => { + const isOpeningCurrentlyPlayingMedia = isPlaying + && currentlyPlaying?.item?.Name + && currentlyPlaying?.item?.Name === item?.Name switch (selectedIndex) { case 0: await CastContext.getPlayServicesState().then((state) => { if (state && state !== PlayServicesState.SUCCESS) CastContext.showPlayServicesErrorDialog(state); else { + // If we're opening a currently playing item, don't restart the media. + // Instead just open controls + console.log({ isOpeningCurrentlyPlayingMedia, currentlyPlaying }) + if (isOpeningCurrentlyPlayingMedia) { + CastContext.showExpandedControls(); + return; + } client.loadMedia({ mediaInfo: { contentUrl: url, @@ -80,6 +90,14 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { }, }, startTime: 0, + }).then(() => { + if (isOpeningCurrentlyPlayingMedia) { + return + } + setCurrentlyPlayingState({ item, url }); + CastContext.showExpandedControls(); + }).catch(e => { + console.log({ e }) }); } }); diff --git a/plugins/withAndroidMainActivityAttributes.js b/plugins/withAndroidMainActivityAttributes.js new file mode 100644 index 00000000..d57b8c93 --- /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; + }); +}; \ No newline at end of file diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx index 03b6363f..1c4f16ba 100644 --- a/providers/PlaybackProvider.tsx +++ b/providers/PlaybackProvider.tsx @@ -224,7 +224,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ useEffect(() => { if (!deviceId || !api?.accessToken) return; - const url = `wss://${api?.basePath + const url = `ws://${api?.basePath .replace("https://", "") .replace("http://", "")}/socket?api_key=${ api?.accessToken From 3bd1177c45002bae965ca1e19035e413945f92e2 Mon Sep 17 00:00:00 2001 From: jakequade Date: Sat, 24 Aug 2024 14:53:33 +1000 Subject: [PATCH 11/15] chromecast controls --- components/PlayButton.tsx | 18 +++++++++--------- plugins/withAndroidMainActivityAttributes.js | 2 +- providers/PlaybackProvider.tsx | 4 +++- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 1f0c59fa..bda14004 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -10,6 +10,7 @@ import { TouchableOpacity, View } from "react-native"; import CastContext, { PlayServicesState, useRemoteMediaClient, + useMediaStatus, } from "react-native-google-cast"; import Animated, { Easing, @@ -35,7 +36,8 @@ const MIN_PLAYBACK_WIDTH = 15; export const PlayButton: React.FC = ({ item, url, ...props }) => { const { showActionSheetWithOptions } = useActionSheet(); const client = useRemoteMediaClient(); - const { setCurrentlyPlayingState, isPlaying, currentlyPlaying } = usePlayback(); + const { setCurrentlyPlayingState } = usePlayback(); + const mediaStatus = useMediaStatus() const [colorAtom] = useAtom(itemThemeColorAtom); @@ -63,9 +65,9 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { cancelButtonIndex, }, async (selectedIndex: number | undefined) => { - const isOpeningCurrentlyPlayingMedia = isPlaying - && currentlyPlaying?.item?.Name - && currentlyPlaying?.item?.Name === item?.Name + const currentTitle = mediaStatus?.mediaInfo?.metadata?.title + const isOpeningCurrentlyPlayingMedia = currentTitle && currentTitle === item?.Name + switch (selectedIndex) { case 0: await CastContext.getPlayServicesState().then((state) => { @@ -73,8 +75,7 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { CastContext.showPlayServicesErrorDialog(state); else { // If we're opening a currently playing item, don't restart the media. - // Instead just open controls - console.log({ isOpeningCurrentlyPlayingMedia, currentlyPlaying }) + // Instead just open controls. if (isOpeningCurrentlyPlayingMedia) { CastContext.showExpandedControls(); return; @@ -91,14 +92,13 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { }, startTime: 0, }).then(() => { + // state is already set when reopening current media, so skip it here. if (isOpeningCurrentlyPlayingMedia) { return } setCurrentlyPlayingState({ item, url }); CastContext.showExpandedControls(); - }).catch(e => { - console.log({ e }) - }); + }) } }); break; diff --git a/plugins/withAndroidMainActivityAttributes.js b/plugins/withAndroidMainActivityAttributes.js index d57b8c93..c5764408 100644 --- a/plugins/withAndroidMainActivityAttributes.js +++ b/plugins/withAndroidMainActivityAttributes.js @@ -39,4 +39,4 @@ module.exports = function withAndroidMainActivityAttributes(config, attributes) config.modResults = addAttributesToMainActivity(config.modResults, attributes); return config; }); -}; \ No newline at end of file +}; diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx index 1c4f16ba..4cbe0f00 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 = `ws://${api?.basePath + const protocol = api?.basePath.includes('https') ? 'wss' : 'ws' + + const url = `${protocol}://${api?.basePath .replace("https://", "") .replace("http://", "")}/socket?api_key=${ api?.accessToken From 2ee6573a90c90237f650a109c692f71ff11b264b Mon Sep 17 00:00:00 2001 From: jakequade Date: Sun, 25 Aug 2024 00:03:34 +1000 Subject: [PATCH 12/15] iOS support --- app.json | 1 + plugins/withExpandedController.js | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 plugins/withExpandedController.js diff --git a/app.json b/app.json index e6babcb7..d4e5e08f 100644 --- a/app.json +++ b/app.json @@ -77,6 +77,7 @@ "com.reactnative.googlecast.RNGCExpandedControllerActivity": true } ], + ["./plugins/withExpandedController.js"], [ "expo-build-properties", { 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; From 318940f7c4eb0872a1ca615057412330b3a956cd Mon Sep 17 00:00:00 2001 From: jakequade Date: Sun, 1 Sep 2024 18:21:40 +1000 Subject: [PATCH 13/15] remove additional play call --- components/PlayButton.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index bda14004..3d6c860d 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -96,7 +96,6 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { if (isOpeningCurrentlyPlayingMedia) { return } - setCurrentlyPlayingState({ item, url }); CastContext.showExpandedControls(); }) } From 58b72b8b754aca5ef4c1a038fcf86931aa638331 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 1 Sep 2024 17:36:15 +0200 Subject: [PATCH 14/15] fix: open expanded controls in header if casting --- components/Chromecast.tsx | 45 ++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 13 deletions(-) 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} > - - + + + + ); }; From fb0b9c83ae25fe264b80c71b677ba60c44912699 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 1 Sep 2024 17:36:27 +0200 Subject: [PATCH 15/15] fix: meta data (including image) when casting --- components/PlayButton.tsx | 65 ++++++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 29884df8..595afd25 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -9,8 +9,8 @@ import { useEffect, useMemo } from "react"; import { TouchableOpacity, View } from "react-native"; import CastContext, { PlayServicesState, - useRemoteMediaClient, useMediaStatus, + useRemoteMediaClient, } from "react-native-google-cast"; import Animated, { Easing, @@ -23,7 +23,10 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { Button } from "./Button"; -import { isCancel } from "axios"; +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; @@ -40,6 +43,7 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { 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 @@ -65,6 +69,7 @@ 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; @@ -86,11 +91,56 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { mediaInfo: { contentUrl: url, contentType: "video/mp4", - metadata: { - type: item.Type === "Episode" ? "tvShow" : "movie", - title: item.Name || "", - subtitle: item.Overview || "", - }, + 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, }) @@ -99,7 +149,6 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { if (isOpeningCurrentlyPlayingMedia) { return; } - setCurrentlyPlayingState({ item, url }); CastContext.showExpandedControls(); }); }