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

feat: add recordings screen #12

Merged
merged 5 commits into from
Oct 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Though this is built in React Native, the immediate focus is to build an iOS nat
Though this project is still in the very early stages, it is maturing quickly. The items below are what is next on the roadmap for feature implementation:

- [x] Event viewing
- [ ] Recording viewing
- [x] Recording viewing
- [ ] View & update config
- [ ] Local user settings
- [ ] User onboarding
Expand Down
14 changes: 13 additions & 1 deletion ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -502,8 +502,12 @@ PODS:
- React-jsi (= 0.72.4)
- React-logger (= 0.72.4)
- React-perflogger (= 0.72.4)
- RNFlashList (1.6.1):
- React-Core
- RNGestureHandler (2.12.1):
- React-Core
- RNLocalize (3.0.2):
- React-Core
- RNReanimated (3.5.0):
- DoubleConversion
- FBLazyVector
Expand Down Expand Up @@ -610,7 +614,9 @@ DEPENDENCIES:
- React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)
- React-utils (from `../node_modules/react-native/ReactCommon/react/utils`)
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- "RNFlashList (from `../node_modules/@shopify/flash-list`)"
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- RNLocalize (from `../node_modules/react-native-localize`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
- RNScreens (from `../node_modules/react-native-screens`)
- RNSVG (from `../node_modules/react-native-svg`)
Expand Down Expand Up @@ -722,8 +728,12 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/react/utils"
ReactCommon:
:path: "../node_modules/react-native/ReactCommon"
RNFlashList:
:path: "../node_modules/@shopify/flash-list"
RNGestureHandler:
:path: "../node_modules/react-native-gesture-handler"
RNLocalize:
:path: "../node_modules/react-native-localize"
RNReanimated:
:path: "../node_modules/react-native-reanimated"
RNScreens:
Expand Down Expand Up @@ -791,7 +801,9 @@ SPEC CHECKSUMS:
React-runtimescheduler: 4941cc1b3cf08b792fbf666342c9fc95f1969035
React-utils: b79f2411931f9d3ea5781404dcbb2fa8a837e13a
ReactCommon: 4b2bdcb50a3543e1c2b2849ad44533686610826d
RNFlashList: 236646d48f224a034f35baa0242e1b77db063b1e
RNGestureHandler: c0d04458598fcb26052494ae23dda8f8f5162b13
RNLocalize: dbea38dcb344bf80ff18a1757b1becf11f70cae4
RNReanimated: fd0dc9a5889bfac6c45a922b26c902dc6185b4dc
RNScreens: b21dc57dfa2b710c30ec600786a3fc223b1b92e7
RNSVG: ed492aaf3af9ca01bc945f7a149d76d62e73ec82
Expand All @@ -801,4 +813,4 @@ SPEC CHECKSUMS:

PODFILE CHECKSUM: b0e3f0f7f573d264ac8c53b7b631360254dc3679

COCOAPODS: 1.12.1
COCOAPODS: 1.13.0
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@
"@react-navigation/bottom-tabs": "^6.5.8",
"@react-navigation/native": "^6.1.7",
"@react-navigation/native-stack": "^6.9.13",
"@shopify/flash-list": "^1.6.1",
"@types/react-native-video": "^5.0.15",
"clsx": "^2.0.0",
"nativewind": "^2.0.11",
"react": "18.2.0",
"react-native": "0.72.4",
"react-native-gesture-handler": "^2.12.1",
"react-native-localize": "^3.0.2",
"react-native-mmkv": "^2.10.2",
"react-native-reanimated": "^3.5.0",
"react-native-safe-area-context": "^4.7.1",
Expand Down
21 changes: 21 additions & 0 deletions src/assets/icons/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Birdseye from './birdseye.svg';
import Camera from './camera.svg';
import DownSquare from './downSquare.svg';
import Event from './event.svg';
import Home from './home.svg';
import PlayRect from './play-rect.svg';
import Recording from './recording.svg';
import Settings from './settings.svg';
import Live from './video-waveform.svg';

export {
PlayRect,
Camera,
Birdseye,
DownSquare,
Event,
Home,
Recording,
Settings,
Live,
};
4 changes: 4 additions & 0 deletions src/assets/icons/play-rect.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions src/assets/icons/recording.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 9 additions & 3 deletions src/components/VideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,29 @@ export const VideoPlayer = ({
videoURI,
isPaused = true,
snapshotURL,
isForcedFullscreen,
onError,
}: {
videoURI: string;
isPaused?: boolean;
snapshotURL?: string;
isForcedFullscreen?: boolean;
onError?: Video['props']['onError'];
}) => {
React.useEffect(() => {}, []);
return (
<Video
poster={snapshotURL}
posterResizeMode="cover"
className="w-full h-full rounded-md"
resizeMode="cover"
className="w-full h-full"
resizeMode="contain"
ignoreSilentSwitch="ignore"
paused={isPaused}
pictureInPicture={true}
controls
source={{uri: videoURI}}
source={{uri: videoURI, type: 'm3u8'}}
fullscreen={isForcedFullscreen}
onError={onError || console.error}
/>
);
};
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './WebRTCView';
export * from './label';
export * from './snapshotCard';
export * from './VideoPlayer';
export * from './loadingView';
11 changes: 11 additions & 0 deletions src/components/loadingView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {ActivityIndicator} from 'react-native';

import {BaseView} from './baseView';

export const LoadingView = () => {
return (
<BaseView className="flex-1">
<ActivityIndicator size={'large'} />
</BaseView>
);
};
56 changes: 44 additions & 12 deletions src/components/snapshotCard.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,40 @@
import {Image, ImageURISource, useWindowDimensions, View} from 'react-native';
import {
Image,
ImageURISource,
TouchableOpacity,
useColorScheme,
useWindowDimensions,
View,
} from 'react-native';

import clsx from 'clsx';

import {BaseText} from './baseText';
import {Label} from './label';
import {FrigateEvent} from '@api';
import {PlayRect} from '@icons';

import {colors, hslFunction} from '../../themeColors';

export const SnapshotCard = ({
imageSource,
camEvent,
imageOverlay,
addtlClasses,
onPlayPress,
}: {
imageSource: ImageURISource['uri'];
camEvent?: FrigateEvent & {lastEventEnded: string};
imageOverlay?: React.ReactNode;
addtlClasses?: string;
onPlayPress?: () => void;
}) => {
const {width} = useWindowDimensions();
const imageWidth = width;
const imageHeight = imageWidth * 0.75;

const isDarkMode = useColorScheme() === 'dark';

return (
<View className={clsx('rounded-xl', addtlClasses)}>
<Image
Expand All @@ -31,20 +45,38 @@ export const SnapshotCard = ({
/>
{!!imageOverlay && imageOverlay}
{!!camEvent && (
<View
className="justify-between flex-row absolute p-1"
style={{width: imageWidth}}>
<Label>
<BaseText className="text-md font-semibold">
{camEvent.label.replaceAll('_', ' ').toLocaleUpperCase()}
</BaseText>
</Label>
{!!camEvent.lastEventEnded && (
<View className="absolute w-full h-full">
<View
className="flex-row justify-between absolute p-1"
style={{width: imageWidth}}>
<Label>
<BaseText className="text-xs text-mutedForeground dark:text-mutedForeground-dark">
{camEvent.lastEventEnded}
<BaseText className="text-md font-semibold">
{camEvent.label.replaceAll('_', ' ').toLocaleUpperCase()}
</BaseText>
</Label>
{!!camEvent.lastEventEnded && (
<Label>
<BaseText className="text-xs text-mutedForeground dark:text-mutedForeground-dark">
{camEvent.lastEventEnded}
</BaseText>
</Label>
)}
</View>
{!!onPlayPress && (
<TouchableOpacity
className="mt-auto ml-auto mr-2 mb-2 rounded-lg p-2 bg-background dark:bg-background-dark opacity-60 items-center justify-center flex"
onPress={onPlayPress}>
<PlayRect
height={64}
width={64}
className="-mb-3.5"
fill={
isDarkMode
? hslFunction(colors.dark.foreground)
: hslFunction(colors.light.foreground)
}
/>
</TouchableOpacity>
)}
</View>
)}
Expand Down
4 changes: 4 additions & 0 deletions src/lib/api/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './useCameraEvents';
export * from './useConfig';
export * from './useRecordings';
export * from './useRecordingSummary';
33 changes: 33 additions & 0 deletions src/lib/api/hooks/useRecordingSummary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {getTimeZone} from 'react-native-localize';
import {useQuery} from 'react-query';

import {API_BASE} from '@env';

import {RecordingResponse} from '../types/recordings';

const URL = `${API_BASE}api/:camera/recordings/summary?timezone=:tz`;

const fetchRecordingSummary = async (cameraName: string) => {
const url = URL.replace(':camera', cameraName).replace(':tz', getTimeZone());
try {
const response = await fetch(url, {method: 'get'});
const data = await response.json();
if (response.ok) {
if (data) {
return Promise.resolve(data as RecordingResponse[]);
}
} else {
return Promise.reject(new Error('ResNotOK'));
}
} catch (e) {
return Promise.reject(e);
}
};

export const useRecordingSummary = (cameraName?: string) => {
return useQuery({
queryFn: () => fetchRecordingSummary(cameraName || ''),
queryKey: ['recordings', cameraName],
enabled: !!cameraName,
});
};
34 changes: 34 additions & 0 deletions src/lib/api/hooks/useRecordings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {useQuery} from 'react-query';

import {API_BASE} from '@env';

// const buildEventUrl = (eventId: string) => {
// const REC_URL = `${API_BASE}vod/event/${eventId}/index.m3u8`;
// return REC_URL;
// };

const URL = `${API_BASE}api/:camera/recordings`;

const fetchRecordings = async (cameraName: string) => {
const url = URL.replace(':camera', cameraName);
try {
const response = await fetch(url, {method: 'get'});
const data = await response.json();
if (response.ok) {
if (data) {
return Promise.resolve(data);
}
} else {
return Promise.reject(new Error('ResNotOK'));
}
} catch (e) {
return Promise.reject(e);
}
};

export const useRecordings = (cameraName?: string) =>
useQuery({
queryFn: () => fetchRecordings(cameraName || ''),
queryKey: ['recordings', cameraName],
enabled: !!cameraName,
});
3 changes: 1 addition & 2 deletions src/lib/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export * from './hooks/useCameraEvents';
export * from './hooks/useConfig';
export * from './hooks';
export * from './getLatestCameraFrame';

//? Types
Expand Down
13 changes: 13 additions & 0 deletions src/lib/api/types/recordings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export interface RecordingResponse {
day: string;
events: number;
hours?: HoursEntity[] | null;
}

export interface HoursEntity {
duration: number;
events: number;
hour: string;
motion: number;
objects: number;
}
45 changes: 45 additions & 0 deletions src/navigation/CameraTabNavigator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from 'react';
import {useColorScheme} from 'react-native';

import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';

import {EventsScreen, LiveViewScreen, RecordingsScreen} from '@screens';
import {hslToHex} from '@utils';

import {colors} from '../../themeColors.js';

import {TabBarIcon} from './components/TabBarIcon';

export type CameraTabsStackParamList = {
Events: undefined;
Live: undefined;
Recordings: undefined;
};

const CameraTabStack = createBottomTabNavigator<CameraTabsStackParamList>();

export const CameraTabNavigator = () => {
const isDarkMode = useColorScheme() === 'dark';

return (
<CameraTabStack.Navigator
screenOptions={({route}) => ({
headerTitle: '',
tabBarIcon: ({color, size}) =>
TabBarIcon({color, size, route, isDarkMode}),
tabBarActiveTintColor: isDarkMode ? 'white' : 'black',
tabBarInactiveTintColor: 'gray',
tabBarStyle: {
borderTopWidth: 0,
backgroundColor: isDarkMode
? hslToHex(colors.dark.background)
: hslToHex(colors.light.background),
},
header: () => null,
})}>
<CameraTabStack.Screen name="Live" component={LiveViewScreen} />
<CameraTabStack.Screen name="Events" component={EventsScreen} />
<CameraTabStack.Screen name="Recordings" component={RecordingsScreen} />
</CameraTabStack.Navigator>
);
};
Loading