Skip to content

Commit

Permalink
Merge pull request #66 from mykhailodanilenko/feature/web-hls-playback
Browse files Browse the repository at this point in the history
Add support for HLS on web
  • Loading branch information
mykhailodanilenko authored Jun 10, 2024
2 parents fba0470 + 267c75b commit 917e278
Show file tree
Hide file tree
Showing 17 changed files with 549 additions and 44 deletions.
1 change: 0 additions & 1 deletion OwnTube.tv/api/peertubeVideosApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ export class PeertubeVideosApi {
nsfw: "false",
isLocal: true,
isLive: false,
hasWebVideoFiles: true,
skipCount: false,
};

Expand Down
2 changes: 1 addition & 1 deletion OwnTube.tv/api/tests/peertubeVideosApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe("peertubeVideosApi", () => {
const peertubeVideosApi = new PeertubeVideosApi();
const total = await peertubeVideosApi.getTotalVideos("http://peertube2.cpy.re");

expect(total).toBe(4);
expect(total).toBe(27);
});

it("should return a list of videos, but not more than the total available videos", async () => {
Expand Down
20 changes: 20 additions & 0 deletions OwnTube.tv/app/+html.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/* eslint-disable react-native/no-raw-text */
import type { PropsWithChildren } from "react";

export default function Root({ children }: PropsWithChildren) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<style id="expo-reset">{"html, body { height: 100%; } #root { display: flex; height: 100%; flex: 1; }"}</style>
<script src="https://vjs.zencdn.net/8.10.0/video.min.js"></script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
{children}
</body>
</html>
);
}
6 changes: 4 additions & 2 deletions OwnTube.tv/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Stack, useNavigation } from "expo-router";
import { Pressable } from "react-native";
import { Platform, Pressable } from "react-native";
import { ROUTES } from "../types";
import { Feather } from "@expo/vector-icons";
import { DarkTheme, DefaultTheme, NavigationProp, ThemeProvider } from "@react-navigation/native";
Expand Down Expand Up @@ -36,10 +36,12 @@ const RootStack = () => {
const queryClient = new QueryClient();

export default function RootLayout() {
const isWeb = Platform.OS === "web";

return (
<QueryClientProvider client={queryClient}>
<AppConfigContextProvider>
<ReactQueryDevtools initialIsOpen={false} />
{isWeb && <ReactQueryDevtools initialIsOpen={false} />}
<ColorSchemeContextProvider>
<RootStack />
</ColorSchemeContextProvider>
Expand Down
13 changes: 13 additions & 0 deletions OwnTube.tv/components/Loader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ActivityIndicator, StyleSheet, View } from "react-native";

export const Loader = () => {
return (
<View style={styles.container}>
<ActivityIndicator size="large" />
</View>
);
};

const styles = StyleSheet.create({
container: { alignItems: "center", flex: 1, height: "100%", justifyContent: "center", width: "100%" },
});
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,12 @@ const styles = StyleSheet.create({
},
contentContainer: { flex: 1, height: "100%", left: 0, position: "absolute", top: 0, width: "100%", zIndex: 1 },
overlay: {
alignItems: "center",
alignSelf: "center",
maxHeight: "100%",
maxWidth: "100%",
display: "flex",
height: "100%",
justifyContent: "center",
width: "100%",
},
playbackControlsContainer: {
alignItems: "center",
Expand Down
33 changes: 24 additions & 9 deletions OwnTube.tv/components/VideoControlsOverlay/components/ScrubBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
GestureHandlerRootView,
PanGestureHandlerEventPayload,
} from "react-native-gesture-handler";
import { useState } from "react";
import { useEffect, useState } from "react";

interface ScrubBarProps {
percentageAvailable: number;
Expand All @@ -15,28 +15,43 @@ interface ScrubBarProps {
duration: number;
}

const INDICATOR_SIZE = 11;
const INDICATOR_SIZE = 16;

export const ScrubBar = ({ percentageAvailable, percentagePosition, onDrag, duration }: ScrubBarProps) => {
const { colors } = useTheme();
const [visibleWidth, setVisibleWidth] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [scrubberPosition, setScrubberPosition] = useState(0);

const handlePan = (event: PanGestureHandlerEventPayload) => {
if (visibleWidth < event.x || event.x < 0) {
return;
}
const newPositionRelation = event.x / visibleWidth;

setScrubberPosition(event.x - INDICATOR_SIZE / 2);
};

const setPosition = (event: PanGestureHandlerEventPayload) => {
const newX = visibleWidth <= event.x ? visibleWidth : event.x < 0 ? 0 : event.x;

const newPositionRelation = (newX - INDICATOR_SIZE / 2) / visibleWidth;

onDrag(Math.floor(newPositionRelation * duration));
};

const pan = Gesture.Pan()
.onUpdate(handlePan)
.onStart(() => setIsDragging(true))
.onEnd(() => setIsDragging(false));
.onEnd((event) => {
setPosition(event);
setIsDragging(false);
});

const scrubberPositionPercentage = percentagePosition - (INDICATOR_SIZE / 2 / visibleWidth) * 100;
useEffect(() => {
if (!isDragging) {
setScrubberPosition((visibleWidth / 100) * percentagePosition);
}
}, [percentagePosition]);

return (
<GestureHandlerRootView style={styles.gestureHandlerContainer}>
Expand All @@ -58,7 +73,7 @@ export const ScrubBar = ({ percentageAvailable, percentagePosition, onDrag, dura
{
backgroundColor: isDragging ? colors.text : colors.primary,
borderColor: colors.background,
left: `${scrubberPositionPercentage}%`,
left: scrubberPosition,
},
]}
/>
Expand All @@ -76,7 +91,7 @@ export const ScrubBar = ({ percentageAvailable, percentagePosition, onDrag, dura
styles.percentagePositionBar,
{
backgroundColor: colors.primary,
width: `${percentagePosition}%`,
width: scrubberPosition,
},
]}
/>
Expand All @@ -95,11 +110,11 @@ const styles = StyleSheet.create({
width: "80%",
},
indicator: {
borderRadius: 12,
borderRadius: INDICATOR_SIZE,
borderWidth: 1,
height: INDICATOR_SIZE,
position: "absolute",
top: -3.5,
top: -6,
width: INDICATOR_SIZE,
zIndex: 4,
},
Expand Down
10 changes: 3 additions & 7 deletions OwnTube.tv/components/VideoList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { View, StyleSheet, ActivityIndicator } from "react-native";
import { ErrorMessage, Typography, VideosByCategory } from "./";
import { View, StyleSheet } from "react-native";
import { ErrorMessage, Loader, Typography, VideosByCategory } from "./";
import { useGetVideosQuery } from "../api";
import { GetVideosVideo } from "../api/peertubeVideosApi";

Expand All @@ -24,11 +24,7 @@ export const VideoList = () => {
}

if (isFetching) {
return (
<View style={{ flex: 1 }}>
<ActivityIndicator size="large" />
</View>
);
return <Loader />;
}

if (data?.videos.length === 0) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { AVPlaybackStatus, AVPlaybackStatusSuccess, ResizeMode, Video } from "expo-av";
import { useRef, useState } from "react";
import { StyleSheet, View } from "react-native";
import { VideoControlsOverlay } from "./VideoControlsOverlay";
import { View } from "react-native";
import { VideoControlsOverlay } from "../VideoControlsOverlay";
import { styles } from "./styles";

interface VideoViewProps {
export interface VideoViewProps {
uri: string;
testID: string;
}

export const VideoView = ({ uri, testID }: VideoViewProps) => {
const VideoView = ({ uri, testID }: VideoViewProps) => {
const videoRef = useRef<Video>(null);
const [isControlsVisible, setIsControlsVisible] = useState(false);
const [playbackStatus, setPlaybackStatus] = useState<AVPlaybackStatusSuccess | null>(null);
Expand Down Expand Up @@ -81,10 +82,4 @@ export const VideoView = ({ uri, testID }: VideoViewProps) => {
);
};

const styles = StyleSheet.create({
container: {
alignItems: "center",
flex: 1,
justifyContent: "center",
},
});
export default VideoView;
163 changes: 163 additions & 0 deletions OwnTube.tv/components/VideoView/VideoView.web.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { useEffect, useRef, useState } from "react";
import { View } from "react-native";
import { VideoControlsOverlay } from "../VideoControlsOverlay";
import Player from "video.js/dist/types/player";
import { VideoViewProps } from "./VideoView";
import { styles } from "./styles";
import "./styles.web.css";
import videojs from "video.js";

declare const window: {
videojs: typeof videojs;
} & Window;

const VideoView = ({ uri, testID }: VideoViewProps) => {
const { videojs } = window;
const videoRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<Player | null>(null);
const [isControlsVisible, setIsControlsVisible] = useState(false);
const [playbackStatus, setPlaybackStatus] = useState({
didJustFinish: false,
isMuted: false,
isPlaying: true,
position: 0,
duration: 1,
playableDuration: 0,
});

const updatePlaybackStatus = (updatedStatus: Partial<typeof playbackStatus>) => {
setPlaybackStatus((prev) => ({ ...prev, ...updatedStatus }));
};

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

const handlePlayPause = () => {
if (playerRef.current?.paused()) {
playerRef.current?.play();
} else {
playerRef.current?.pause();
}
};

const handleRW = () => {
playerRef.current?.currentTime(playbackStatus.position / 1000 - 10);
};

const handleFF = () => {
playerRef.current?.currentTime(playbackStatus.position / 1000 + 10);
};

const toggleMute = () => {
const newValue = !playerRef.current?.muted();
playerRef.current?.muted(newValue);
updatePlaybackStatus({ isMuted: newValue });
};

const handleReplay = () => {
playerRef.current?.currentTime(0);
};

const handleJumpTo = (position: number) => {
playerRef.current?.tech().setCurrentTime(position / 1000);
};

const options = {
autoplay: true,
controls: false,
html5: {
vhs: {
overrideNative: true,
},
},
sources: [
{
src: uri,
},
],
children: ["MediaLoader"],
};

const onReady = (player: Player) => {
playerRef.current = player;

player.on("loadedmetadata", () => {
updatePlaybackStatus({
duration: Math.floor(playerRef.current?.duration() ?? 0) * 1000,
playableDuration: playerRef.current?.bufferedEnd(),
});
});

player.on("play", () => {
updatePlaybackStatus({ isPlaying: true, didJustFinish: false });
});

player.on("pause", () => {
updatePlaybackStatus({ isPlaying: false });
});

player.on("timeupdate", () => {
updatePlaybackStatus({ position: Math.floor(playerRef.current?.currentTime() ?? 0) * 1000 });
});

player.on("ended", () => {
updatePlaybackStatus({ didJustFinish: true });
});
};

useEffect(() => {
if (!videojs) return;

if (!playerRef.current) {
const videoElement = document.createElement("video-js");

videoElement.classList.add("vjs-big-play-centered");
videoRef.current?.appendChild(videoElement);

const player = (playerRef.current = videojs(videoElement, options, () => {
onReady && onReady(player);
}));
} else {
const player = playerRef.current;

player.options(options);
}
}, [options, videojs]);

useEffect(() => {
const player = playerRef.current;

return () => {
if (player && !player.isDisposed()) {
player.dispose();
playerRef.current = null;
}
};
}, []);

return (
<View style={styles.container}>
<VideoControlsOverlay
handlePlayPause={handlePlayPause}
isPlaying={playbackStatus?.isPlaying}
isVisible={isControlsVisible}
onOverlayPress={toggleControls}
handleRW={handleRW}
handleFF={handleFF}
duration={playbackStatus?.duration}
availableDuration={playbackStatus?.playableDuration}
position={playbackStatus?.position}
toggleMute={toggleMute}
isMute={playbackStatus?.isMuted}
shouldReplay={playbackStatus?.didJustFinish}
handleReplay={handleReplay}
handleJumpTo={handleJumpTo}
>
<div style={{ position: "relative" }} ref={videoRef} data-testid={`${testID}-video-playback`} />
</VideoControlsOverlay>
</View>
);
};

export default VideoView;
1 change: 1 addition & 0 deletions OwnTube.tv/components/VideoView/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./VideoView";
Loading

0 comments on commit 917e278

Please sign in to comment.