Skip to content

Commit

Permalink
Merge pull request #53 from mykhailodanilenko/feature/add-video-view
Browse files Browse the repository at this point in the history
add video screen with video view (#8)
  • Loading branch information
mykhailodanilenko authored May 23, 2024
2 parents 57273ea + fa4bd2f commit 0f4aa75
Show file tree
Hide file tree
Showing 14 changed files with 321 additions and 13 deletions.
3 changes: 2 additions & 1 deletion OwnTube.tv/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"plugins": ["@typescript-eslint", "react", "react-native"],
"rules": {
"react-native/no-raw-text": ["error", { "skip": ["Typography"] }],
"react/prop-types": "off"
"react/prop-types": "off",
"react-native/no-inline-styles": "off"
},
"settings": {
"react": {
Expand Down
117 changes: 117 additions & 0 deletions OwnTube.tv/components/VideoControlsOverlay/VideoControlsOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { PropsWithChildren, useMemo } from "react";
import { Dimensions, Pressable, StyleSheet, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useTheme } from "@react-navigation/native";
import { ScrubBar } from "./components/ScrubBar";

interface VideoControlsOverlayProps {
isVisible: boolean;
onOverlayPress: () => void;
isPlaying?: boolean;
handlePlayPause: () => void;
handleRW: () => void;
handleFF: () => void;
percentageAvailable: number;
percentagePosition: number;
toggleMute: () => void;
isMute?: boolean;
shouldReplay?: boolean;
handleReplay: () => void;
}

export const VideoControlsOverlay = ({
children,
isVisible,
onOverlayPress,
isPlaying,
handlePlayPause,
handleRW,
handleFF,
percentageAvailable,
percentagePosition,
toggleMute,
isMute = false,
shouldReplay,
handleReplay,
}: PropsWithChildren<VideoControlsOverlayProps>) => {
const { colors } = useTheme();

const buttonScale = useMemo(() => {
const { width, height } = Dimensions.get("window");
const isHorizontal = width > height;

return isHorizontal ? 1 : 0.5;
}, []);

const centralIconName = useMemo(() => {
return isPlaying ? "pause" : shouldReplay ? "reload" : "play";
}, [isPlaying, shouldReplay]);

return (
<Pressable style={styles.overlay} onPress={onOverlayPress}>
{isVisible ? (
<View style={styles.contentContainer}>
<View style={styles.topControlsContainer}>
<Pressable onPress={toggleMute}>
<Ionicons name={`volume-${isMute ? "mute" : "high"}`} size={48 * buttonScale} color={colors.primary} />
</Pressable>
</View>
<View style={styles.playbackControlsContainer}>
<View style={{ flexDirection: "row", gap: 48 * buttonScale }}>
<Pressable onPress={handleRW}>
<Ionicons name={"play-back"} size={96 * buttonScale} color={colors.primary} />
</Pressable>
<Pressable onPress={shouldReplay ? handleReplay : handlePlayPause}>
<Ionicons name={centralIconName} size={96 * buttonScale} color={colors.primary} />
</Pressable>
<Pressable onPress={handleFF}>
<Ionicons name={"play-forward"} size={96 * buttonScale} color={colors.primary} />
</Pressable>
</View>
</View>
<View style={styles.bottomControlsContainer}>
<ScrubBar percentageAvailable={percentageAvailable} percentagePosition={percentagePosition} />
</View>
</View>
) : null}
{children}
</Pressable>
);
};

const styles = StyleSheet.create({
bottomControlsContainer: {
alignItems: "center",
bottom: 0,
height: "20%",
justifyContent: "center",
left: 0,
position: "absolute",
width: "100%",
},
contentContainer: { flex: 1, height: "100%", left: 0, position: "absolute", top: 0, width: "100%", zIndex: 1 },
overlay: {
alignSelf: "center",
maxHeight: "100%",
maxWidth: "100%",
},
playbackControlsContainer: {
alignItems: "center",
flex: 1,
height: "100%",
justifyContent: "center",
width: "100%",
},
topControlsContainer: {
alignItems: "center",
flexDirection: "row",
height: "20%",
justifyContent: "flex-end",
left: 0,
paddingRight: "5%",
position: "absolute",
top: 0,
width: "100%",
zIndex: 1,
},
});
66 changes: 66 additions & 0 deletions OwnTube.tv/components/VideoControlsOverlay/components/ScrubBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { StyleSheet, View } from "react-native";
import { useTheme } from "@react-navigation/native";

interface ScrubBarProps {
percentageAvailable: number;
percentagePosition: number;
}

export const ScrubBar = ({ percentageAvailable, percentagePosition }: ScrubBarProps) => {
const { colors } = useTheme();

return (
<View
style={[
styles.scrubBarContainer,
{
backgroundColor: colors.background,
borderColor: colors.background,
},
]}
>
<View
style={[
styles.percentageAvailableBar,
{
backgroundColor: colors.border,
width: `${percentageAvailable}%`,
},
]}
/>
<View
style={[
styles.percentagePositionBar,
{
backgroundColor: colors.primary,
width: `${percentagePosition}%`,
},
]}
/>
</View>
);
};

const styles = StyleSheet.create({
percentageAvailableBar: {
height: 3,
left: 0,
position: "absolute",
top: 1,
zIndex: 1,
},
percentagePositionBar: {
height: 3,
left: 0,
position: "absolute",
top: 1,
zIndex: 2,
},
scrubBarContainer: {
borderLeftWidth: 1,
borderRightWidth: 1,
flexDirection: "row",
height: 5,
width: "80%",
},
});
1 change: 1 addition & 0 deletions OwnTube.tv/components/VideoControlsOverlay/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./VideoControlsOverlay";
12 changes: 8 additions & 4 deletions OwnTube.tv/components/VideoThumbnail.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { View, Image, StyleSheet, TouchableOpacity } from "react-native";
import { getThumbnailDimensions } from "../utils";
import { useAppConfigContext } from "../contexts";
import type { Video } from "../types";
import { ROUTES, Video } from "../types";
import { Typography } from "./Typography";
import { useTheme } from "@react-navigation/native";
import { useNavigation, useTheme } from "@react-navigation/native";
import { FC } from "react";
import { RootStackParams } from "../navigation";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";

interface VideoThumbnailProps {
video: Video;
}

export const VideoThumbnail: React.FC<VideoThumbnailProps> = ({ video }) => {
export const VideoThumbnail: FC<VideoThumbnailProps> = ({ video }) => {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParams>>();
const { source } = useAppConfigContext();

const imageUrl = video.thumbnailUrl || `${source}/default-thumbnail.jpg`;
Expand All @@ -19,7 +23,7 @@ export const VideoThumbnail: React.FC<VideoThumbnailProps> = ({ video }) => {
return (
<TouchableOpacity
style={[styles.videoThumbnailContainer, { height, width }, { backgroundColor: colors.card }]}
onPress={() => console.log("Video Pressed", video.name)}
onPress={() => navigation.navigate(ROUTES.VIDEO)}
>
<Image source={{ uri: imageUrl }} style={styles.videoImage} />
<View style={styles.textContainer}>
Expand Down
95 changes: 95 additions & 0 deletions OwnTube.tv/components/VideoView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { AVPlaybackStatus, AVPlaybackStatusSuccess, ResizeMode, Video } from "expo-av";
import { useMemo, useRef, useState } from "react";
import { Platform, View } from "react-native";
import { VideoControlsOverlay } from "./VideoControlsOverlay";

const hlsUri =
"https://tube.extinctionrebellion.fr/static/streaming-playlists/hls/8803fdd3-4ac9-49d0-8dcf-ff1586e9e458/1663f4f5-bb3d-44ec-8a21-6195e6407ba8-master.m3u8";

const mp4Uri = "https://tube.extinctionrebellion.fr/download/videos/8803fdd3-4ac9-49d0-8dcf-ff1586e9e458-720.mp4";

export const VideoView = () => {
const videoRef = useRef<Video>(null);
const isWeb = Platform.OS === "web";
const [isControlsVisible, setIsControlsVisible] = useState(false);
const [playbackStatus, setPlaybackStatus] = useState<AVPlaybackStatusSuccess | null>(null);

const toggleControls = () => {
setIsControlsVisible((prev) => !prev);
};

const handlePlayPause = () => {
videoRef.current?.[playbackStatus?.isPlaying ? "pauseAsync" : "playAsync"]();
};

const handlePlaybackStatusUpdate = (status: AVPlaybackStatus) => {
if (status.isLoaded) {
setPlaybackStatus(status);
} else if (status.error) {
console.error(status.error);
}
};

const handleRW = () => {
videoRef.current?.setPositionAsync((playbackStatus?.positionMillis ?? 0) - 10_000);
};

const handleFF = () => {
videoRef.current?.setPositionAsync((playbackStatus?.positionMillis ?? 0) + 10_000);
};

const toggleMute = () => {
videoRef.current?.setIsMutedAsync(!playbackStatus?.isMuted);
};

const { percentageAvailable, percentagePosition } = useMemo(() => {
const { durationMillis = 1, positionMillis = 1, playableDurationMillis = 1 } = playbackStatus || {};

if (!playbackStatus) {
return { percentageAvailable: 0, percentagePosition: 0 };
}

return {
percentageAvailable: (playableDurationMillis / playableDurationMillis) * 100,
percentagePosition: (positionMillis / durationMillis) * 100,
};
}, [playbackStatus]);

const handleReplay = () => {
videoRef.current?.playFromPositionAsync(0);
};

return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<VideoControlsOverlay
handlePlayPause={handlePlayPause}
isPlaying={playbackStatus?.isPlaying}
isVisible={isControlsVisible}
onOverlayPress={toggleControls}
handleRW={handleRW}
handleFF={handleFF}
percentagePosition={percentagePosition}
percentageAvailable={percentageAvailable}
toggleMute={toggleMute}
isMute={playbackStatus?.isMuted}
shouldReplay={playbackStatus?.didJustFinish}
handleReplay={handleReplay}
>
<Video
shouldPlay
resizeMode={ResizeMode.CONTAIN}
ref={videoRef}
source={{ uri: isWeb ? mp4Uri : hlsUri }}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
videoStyle={{ position: "relative" }}
/>
</VideoControlsOverlay>
</View>
);
};
4 changes: 2 additions & 2 deletions OwnTube.tv/components/VideosByCategory.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { View, StyleSheet } from "react-native";
import { CategoryScroll, Typography, VideoThumbnail } from "./";
import type { VideoCategory } from "../types";
import { FC } from "react";

interface VideosByCategoryProps {
category: VideoCategory;
}

export const VideosByCategory: React.FC<VideosByCategoryProps> = ({ category }) => {
export const VideosByCategory: FC<VideosByCategoryProps> = ({ category }) => {
return (
<View style={styles.container}>
<Typography style={styles.categoryTitle}>{category.label}</Typography>

<CategoryScroll>
{category.videos.map((video) => (
<VideoThumbnail key={video.id} video={video} />
Expand Down
1 change: 1 addition & 0 deletions OwnTube.tv/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from "./shared";
export * from "./VideosByCategory";
export * from "./CategoryScroll";
export * from "./VideoThumbnail";
export * from "./VideoView";
11 changes: 9 additions & 2 deletions OwnTube.tv/navigation/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { useColorSchemeContext } from "../contexts";
import { Pressable } from "react-native";
import { Feather } from "@expo/vector-icons";
import { HomeScreen, SettingsScreen } from "../screens";
import { HomeScreen, SettingsScreen, VideoScreen } from "../screens";
import { ROUTES } from "../types";

const Stack = createNativeStackNavigator();
Expand All @@ -27,8 +27,15 @@ export const Navigation = () => {
),
})}
/>
<Stack.Screen name={ROUTES.SETTINGS} component={SettingsScreen} />
<Stack.Screen options={{ title: "Settings" }} name={ROUTES.SETTINGS} component={SettingsScreen} />
<Stack.Screen options={{ title: "Video" }} name={ROUTES.VIDEO} component={VideoScreen} />
</Stack.Navigator>
</NavigationContainer>
);
};

export type RootStackParams = {
[ROUTES.HOME]: undefined;
[ROUTES.SETTINGS]: undefined;
[ROUTES.VIDEO]: undefined;
};
9 changes: 9 additions & 0 deletions OwnTube.tv/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 0f4aa75

Please sign in to comment.