Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ExpandedController to chromecast usage #106

Merged
merged 12 commits into from
Sep 1, 2024
7 changes: 7 additions & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@
}
}
],
[
"./plugins/withAndroidMainActivityAttributes",
{
"com.reactnative.googlecast.RNGCExpandedControllerActivity": true
}
],
["./plugins/withExpandedController.js"],
[
"expo-build-properties",
{
Expand Down
45 changes: 32 additions & 13 deletions components/Chromecast.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -25,6 +27,7 @@ export const Chromecast: React.FC<Props> = ({
const devices = useDevices();
const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager();
const mediaStatus = useMediaStatus();

useEffect(() => {
(async () => {
Expand All @@ -38,31 +41,47 @@ export const Chromecast: React.FC<Props> = ({

if (background === "transparent")
return (
<View
<TouchableOpacity
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
className="rounded-full h-10 w-10 flex items-center justify-center b"
{...props}
>
<CastButton style={{ tintColor: "white", height, width }} />
</View>
<Feather name="cast" size={22} color={"white"} />
</TouchableOpacity>
);

if (Platform.OS === "android")
return (
<View
<TouchableOpacity
onPress={() => {
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}
>
<CastButton style={{ tintColor: "white", height, width }} />
</View>
<Feather name="cast" size={22} color={"white"} />
</TouchableOpacity>
);

return (
<BlurView
intensity={100}
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
<TouchableOpacity
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
{...props}
>
<CastButton style={{ tintColor: "white", height, width }} />
</BlurView>
<BlurView
intensity={100}
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
{...props}
>
<Feather name="cast" size={22} color={"white"} />
</BlurView>
</TouchableOpacity>
);
};
96 changes: 83 additions & 13 deletions components/PlayButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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<typeof Button> {
item?: BaseItemDto | null;
Expand All @@ -33,11 +38,12 @@ const MIN_PLAYBACK_WIDTH = 15;

export const PlayButton: React.FC<Props> = ({ 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
Expand All @@ -63,24 +69,88 @@ export const PlayButton: React.FC<Props> = ({ 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;
Expand Down
42 changes: 42 additions & 0 deletions plugins/withAndroidMainActivityAttributes.js
Original file line number Diff line number Diff line change
@@ -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;
});
};
20 changes: 20 additions & 0 deletions plugins/withExpandedController.js
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 3 additions & 1 deletion providers/PlaybackProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down