Add video support
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
@@ -28,6 +28,21 @@
|
|||||||
<data android:mimeType="image/png" />
|
<data android:mimeType="image/png" />
|
||||||
<data android:mimeType="image/webp" />
|
<data android:mimeType="image/webp" />
|
||||||
<data android:mimeType="image/gif" />
|
<data android:mimeType="image/gif" />
|
||||||
|
<data android:mimeType="video/av01" />
|
||||||
|
<data android:mimeType="video/3gpp" />
|
||||||
|
<data android:mimeType="video/avc" />
|
||||||
|
<data android:mimeType="video/hevc" />
|
||||||
|
<data android:mimeType="video/x-matroska" />
|
||||||
|
<data android:mimeType="video/mp2t" />
|
||||||
|
<data android:mimeType="video/mp4" />
|
||||||
|
<data android:mimeType="video/mp42" />
|
||||||
|
<data android:mimeType="video/mp43" />
|
||||||
|
<data android:mimeType="video/mp4v-es" />
|
||||||
|
<data android:mimeType="video/mpeg" />
|
||||||
|
<data android:mimeType="video/mpeg2" />
|
||||||
|
<data android:mimeType="video/x-vnd.on2.vp8" />
|
||||||
|
<data android:mimeType="video/x-vnd.on2.vp9" />
|
||||||
|
<data android:mimeType="video/webm" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||||
@@ -37,6 +52,21 @@
|
|||||||
<data android:mimeType="image/png" />
|
<data android:mimeType="image/png" />
|
||||||
<data android:mimeType="image/webp" />
|
<data android:mimeType="image/webp" />
|
||||||
<data android:mimeType="image/gif" />
|
<data android:mimeType="image/gif" />
|
||||||
|
<data android:mimeType="video/av01" />
|
||||||
|
<data android:mimeType="video/3gpp" />
|
||||||
|
<data android:mimeType="video/avc" />
|
||||||
|
<data android:mimeType="video/hevc" />
|
||||||
|
<data android:mimeType="video/x-matroska" />
|
||||||
|
<data android:mimeType="video/mp2t" />
|
||||||
|
<data android:mimeType="video/mp4" />
|
||||||
|
<data android:mimeType="video/mp42" />
|
||||||
|
<data android:mimeType="video/mp43" />
|
||||||
|
<data android:mimeType="video/mp4v-es" />
|
||||||
|
<data android:mimeType="video/mpeg" />
|
||||||
|
<data android:mimeType="video/mpeg2" />
|
||||||
|
<data android:mimeType="video/x-vnd.on2.vp8" />
|
||||||
|
<data android:mimeType="video/x-vnd.on2.vp9" />
|
||||||
|
<data android:mimeType="video/webm" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
|
@@ -1,13 +1,20 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { HelperText, Text, TextInput, useTheme } from 'react-native-paper';
|
import { HelperText, Text, TextInput, useTheme } from 'react-native-paper';
|
||||||
import { Image, LayoutAnimation } from 'react-native';
|
import { Image, LayoutAnimation } from 'react-native';
|
||||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||||
import { LoadingView, MemeFail, MemeTagSelector } from '..';
|
import { LoadingView, MemeFail, MemeTagSelector } from '..';
|
||||||
import { getFilenameFromUri, validateMemeTitle } from '../../utilities';
|
import {
|
||||||
import { Dimensions, StagingMeme } from '../../types';
|
getFilenameFromUri,
|
||||||
|
getMemeTypeFromMimeType,
|
||||||
|
validateMemeTitle,
|
||||||
|
} from '../../utilities';
|
||||||
|
import { StagingMeme } from '../../types';
|
||||||
|
import { useMemeDimensions } from '../../hooks';
|
||||||
|
import { MEME_TYPE } from '../../database';
|
||||||
|
import Video from 'react-native-video';
|
||||||
|
|
||||||
const memeEditorStyles = {
|
const memeEditorStyles = {
|
||||||
image: {
|
media: {
|
||||||
marginBottom: 15,
|
marginBottom: 15,
|
||||||
borderRadius: 5,
|
borderRadius: 5,
|
||||||
},
|
},
|
||||||
@@ -26,6 +33,7 @@ const memeEditorStyles = {
|
|||||||
const MemeEditor = ({
|
const MemeEditor = ({
|
||||||
uri,
|
uri,
|
||||||
mimeType,
|
mimeType,
|
||||||
|
loading,
|
||||||
setLoading,
|
setLoading,
|
||||||
error,
|
error,
|
||||||
setError,
|
setError,
|
||||||
@@ -44,7 +52,59 @@ const MemeEditor = ({
|
|||||||
const { width } = useSafeAreaFrame();
|
const { width } = useSafeAreaFrame();
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
|
|
||||||
const [dimensions, setDimensions] = useState<Dimensions>();
|
const { dimensions } = useMemeDimensions(
|
||||||
|
uri,
|
||||||
|
mimeType,
|
||||||
|
useMemo(
|
||||||
|
() => () => {
|
||||||
|
setLoading(false);
|
||||||
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||||
|
},
|
||||||
|
[setLoading],
|
||||||
|
),
|
||||||
|
useMemo(() => (errorIn: Error) => setError(errorIn), [setError]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mediaComponent = useMemo(() => {
|
||||||
|
if (!mimeType || !dimensions) return <></>;
|
||||||
|
|
||||||
|
const dimensionStyles = {
|
||||||
|
width: width * 0.92,
|
||||||
|
height: Math.max(
|
||||||
|
Math.min((width * 0.92) / dimensions.aspectRatio, 500),
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const memeType = getMemeTypeFromMimeType(mimeType);
|
||||||
|
if (!memeType) return <></>;
|
||||||
|
|
||||||
|
switch (memeType) {
|
||||||
|
case MEME_TYPE.IMAGE:
|
||||||
|
case MEME_TYPE.GIF: {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
source={{ uri }}
|
||||||
|
style={[memeEditorStyles.media, dimensionStyles]}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case MEME_TYPE.VIDEO: {
|
||||||
|
return (
|
||||||
|
<Video
|
||||||
|
source={{ uri }}
|
||||||
|
style={[memeEditorStyles.media, dimensionStyles]}
|
||||||
|
resizeMode="contain"
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [dimensions, mimeType, uri, width]);
|
||||||
|
|
||||||
if (!uri || !mimeType || !staging) return <LoadingView />;
|
if (!uri || !mimeType || !staging) return <LoadingView />;
|
||||||
|
|
||||||
@@ -70,51 +130,15 @@ const MemeEditor = ({
|
|||||||
width: width * 0.92,
|
width: width * 0.92,
|
||||||
height: width * 0.92,
|
height: width * 0.92,
|
||||||
},
|
},
|
||||||
memeEditorStyles.image,
|
memeEditorStyles.media,
|
||||||
]}
|
]}
|
||||||
iconSize={50}
|
iconSize={50}
|
||||||
/>
|
/>
|
||||||
|
) : // eslint-disable-next-line unicorn/no-nested-ternary
|
||||||
|
loading || !dimensions ? (
|
||||||
|
<></>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
mediaComponent
|
||||||
source={{ uri }}
|
|
||||||
style={[
|
|
||||||
dimensions
|
|
||||||
? {
|
|
||||||
width: width * 0.92,
|
|
||||||
height: Math.max(
|
|
||||||
Math.min(
|
|
||||||
((width * 0.92) / dimensions.width) * dimensions.height,
|
|
||||||
500,
|
|
||||||
),
|
|
||||||
100,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: // eslint-disable-next-line react-native/no-inline-styles
|
|
||||||
{
|
|
||||||
width: width * 0.92,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
memeEditorStyles.image,
|
|
||||||
]}
|
|
||||||
resizeMode="contain"
|
|
||||||
onLoad={event => {
|
|
||||||
setDimensions({
|
|
||||||
width: event.nativeEvent.source.width,
|
|
||||||
height: event.nativeEvent.source.height,
|
|
||||||
});
|
|
||||||
setLoading(false);
|
|
||||||
LayoutAnimation.configureNext(
|
|
||||||
LayoutAnimation.Presets.easeInEaseOut,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onError={() =>
|
|
||||||
setError(
|
|
||||||
new Error(
|
|
||||||
'The URI for this meme appears to be broken. This may have been caused by the file being moved or deleted.',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<Text
|
<Text
|
||||||
variant="bodySmall"
|
variant="bodySmall"
|
||||||
|
@@ -1,12 +1,13 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { StyleSheet, View } from 'react-native';
|
import { StyleSheet, View } from 'react-native';
|
||||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||||
import { useImageDimensions } from '@react-native-community/hooks';
|
|
||||||
import { AndroidScoped } from 'react-native-file-access';
|
import { AndroidScoped } from 'react-native-file-access';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { Meme } from '../../database';
|
import { MEME_TYPE, Meme } from '../../database';
|
||||||
import { RootState } from '../../state';
|
import { RootState } from '../../state';
|
||||||
import { AnimatedImage, LoadingView, MemeFail } from '..';
|
import { AnimatedImage, LoadingView, MemeFail } from '..';
|
||||||
|
import { useMemeDimensions } from '../../hooks';
|
||||||
|
import Video from 'react-native-video';
|
||||||
|
|
||||||
const memeViewItemStyles = StyleSheet.create({
|
const memeViewItemStyles = StyleSheet.create({
|
||||||
view: {
|
view: {
|
||||||
@@ -24,7 +25,34 @@ const MemeViewItem = ({ meme }: { meme: Meme }) => {
|
|||||||
|
|
||||||
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
||||||
|
|
||||||
const { dimensions, loading, error } = useImageDimensions({ uri });
|
const { dimensions, loading, error } = useMemeDimensions(uri, meme.mimeType);
|
||||||
|
|
||||||
|
const mediaComponent = useMemo(() => {
|
||||||
|
if (!dimensions) return <></>;
|
||||||
|
|
||||||
|
const dimensionStyles =
|
||||||
|
dimensions.aspectRatio > width / (height - 128)
|
||||||
|
? {
|
||||||
|
width,
|
||||||
|
height: width / (dimensions.width / dimensions.height),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
width: (height - 128) * (dimensions.width / dimensions.height),
|
||||||
|
height: height - 128,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (meme.memeType) {
|
||||||
|
case MEME_TYPE.IMAGE:
|
||||||
|
case MEME_TYPE.GIF: {
|
||||||
|
return <AnimatedImage source={{ uri }} style={dimensionStyles} />;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return (
|
||||||
|
<Video source={{ uri }} style={dimensionStyles} paused controls />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [dimensions, height, meme.memeType, uri, width]);
|
||||||
|
|
||||||
if (!error && (loading || !dimensions)) {
|
if (!error && (loading || !dimensions)) {
|
||||||
return <LoadingView style={{ width, height }} />;
|
return <LoadingView style={{ width, height }} />;
|
||||||
@@ -41,21 +69,7 @@ const MemeViewItem = ({ meme }: { meme: Meme }) => {
|
|||||||
iconSize={50}
|
iconSize={50}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AnimatedImage
|
mediaComponent
|
||||||
source={{ uri }}
|
|
||||||
style={
|
|
||||||
dimensions.aspectRatio > width / (height - 128)
|
|
||||||
? {
|
|
||||||
width,
|
|
||||||
height: width / (dimensions.width / dimensions.height),
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
width:
|
|
||||||
(height - 128) * (dimensions.width / dimensions.height),
|
|
||||||
height: height - 128,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
@@ -1,13 +1,13 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Image, TouchableHighlight } from 'react-native';
|
import { Image, TouchableHighlight } from 'react-native';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||||
import { useImageDimensions } from '@react-native-community/hooks';
|
|
||||||
import { AndroidScoped } from 'react-native-file-access';
|
import { AndroidScoped } from 'react-native-file-access';
|
||||||
import { Meme } from '../../../database';
|
import { MEME_TYPE, Meme } from '../../../database';
|
||||||
import { RootState } from '../../../state';
|
import { RootState } from '../../../state';
|
||||||
import { MemeFail } from '..';
|
import { MemeFail } from '..';
|
||||||
import { getFontAwesome5IconSize } from '../../../utilities';
|
import { getFontAwesome5IconSize } from '../../../utilities';
|
||||||
|
import { useMemeDimensions } from '../../../hooks';
|
||||||
|
|
||||||
const MemesGridItem = ({
|
const MemesGridItem = ({
|
||||||
meme,
|
meme,
|
||||||
@@ -29,7 +29,32 @@ const MemesGridItem = ({
|
|||||||
|
|
||||||
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
||||||
|
|
||||||
const { dimensions, loading, error } = useImageDimensions({ uri });
|
const { dimensions, loading, error } = useMemeDimensions(uri, meme.mimeType);
|
||||||
|
|
||||||
|
const itemWidth = (width * 0.92 - 5) / gridColumns;
|
||||||
|
|
||||||
|
const mediaComponent = useMemo(() => {
|
||||||
|
switch (meme.memeType) {
|
||||||
|
case MEME_TYPE.IMAGE:
|
||||||
|
case MEME_TYPE.GIF:
|
||||||
|
case MEME_TYPE.VIDEO: {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
source={{ uri }}
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
width: itemWidth,
|
||||||
|
height: itemWidth,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [itemWidth, meme.memeType, uri]);
|
||||||
|
|
||||||
if (!error && (loading || !dimensions)) return <></>;
|
if (!error && (loading || !dimensions)) return <></>;
|
||||||
|
|
||||||
@@ -44,15 +69,7 @@ const MemesGridItem = ({
|
|||||||
iconSize={getFontAwesome5IconSize(gridColumns)}
|
iconSize={getFontAwesome5IconSize(gridColumns)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
mediaComponent
|
||||||
source={{ uri }}
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
width: (width * 0.92 - 5) / gridColumns,
|
|
||||||
height: (width * 0.92 - 5) / gridColumns,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</TouchableHighlight>
|
</TouchableHighlight>
|
||||||
);
|
);
|
||||||
|
@@ -1,13 +1,13 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Image, StyleSheet, View } from 'react-native';
|
import { Image, StyleSheet, View } from 'react-native';
|
||||||
import { Text, TouchableRipple } from 'react-native-paper';
|
import { Text, TouchableRipple } from 'react-native-paper';
|
||||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||||
import { useImageDimensions } from '@react-native-community/hooks';
|
|
||||||
import { AndroidScoped } from 'react-native-file-access';
|
import { AndroidScoped } from 'react-native-file-access';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { Meme } from '../../../database';
|
import { MEME_TYPE, Meme } from '../../../database';
|
||||||
import { MemeFail } from '..';
|
import { MemeFail } from '..';
|
||||||
import { RootState } from '../../../state';
|
import { RootState } from '../../../state';
|
||||||
|
import { useMemeDimensions } from '../../../hooks';
|
||||||
|
|
||||||
const memesListItemStyles = StyleSheet.create({
|
const memesListItemStyles = StyleSheet.create({
|
||||||
view: {
|
view: {
|
||||||
@@ -50,7 +50,20 @@ const MemesListItem = ({
|
|||||||
|
|
||||||
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
||||||
|
|
||||||
const { dimensions, loading, error } = useImageDimensions({ uri });
|
const { dimensions, loading, error } = useMemeDimensions(uri, meme.mimeType);
|
||||||
|
|
||||||
|
const mediaComponent = useMemo(() => {
|
||||||
|
switch (meme.memeType) {
|
||||||
|
case MEME_TYPE.IMAGE:
|
||||||
|
case MEME_TYPE.GIF:
|
||||||
|
case MEME_TYPE.VIDEO: {
|
||||||
|
return <Image source={{ uri }} style={[memesListItemStyles.image]} />;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [meme.memeType, uri]);
|
||||||
|
|
||||||
if (!error && (loading || !dimensions)) return <></>;
|
if (!error && (loading || !dimensions)) return <></>;
|
||||||
|
|
||||||
@@ -62,7 +75,7 @@ const MemesListItem = ({
|
|||||||
{error ? (
|
{error ? (
|
||||||
<MemeFail style={memesListItemStyles.image} />
|
<MemeFail style={memesListItemStyles.image} />
|
||||||
) : (
|
) : (
|
||||||
<Image source={{ uri }} style={memesListItemStyles.image} />
|
mediaComponent
|
||||||
)}
|
)}
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
@@ -1,13 +1,13 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Image, StyleSheet, TouchableHighlight } from 'react-native';
|
import { Image, StyleSheet, TouchableHighlight } from 'react-native';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||||
import { AndroidScoped } from 'react-native-file-access';
|
import { AndroidScoped } from 'react-native-file-access';
|
||||||
import { useImageDimensions } from '@react-native-community/hooks';
|
import { MEME_TYPE, Meme } from '../../../database';
|
||||||
import { Meme } from '../../../database';
|
|
||||||
import { RootState } from '../../../state';
|
import { RootState } from '../../../state';
|
||||||
import { MemeFail } from '..';
|
import { MemeFail } from '..';
|
||||||
import { getFontAwesome5IconSize } from '../../../utilities';
|
import { getFontAwesome5IconSize } from '../../../utilities';
|
||||||
|
import { useMemeDimensions } from '../../../hooks';
|
||||||
|
|
||||||
const memeMasonryItemStyles = StyleSheet.create({
|
const memeMasonryItemStyles = StyleSheet.create({
|
||||||
view: {
|
view: {
|
||||||
@@ -39,7 +39,35 @@ const MemesMasonryItem = ({
|
|||||||
|
|
||||||
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
|
||||||
|
|
||||||
const { dimensions, loading, error } = useImageDimensions({ uri });
|
const { dimensions, loading, error } = useMemeDimensions(uri, meme.mimeType);
|
||||||
|
|
||||||
|
const itemWidth = (width * 0.92 - 5) / masonryColumns - 5;
|
||||||
|
const itemHeight =
|
||||||
|
((width * 0.92) / masonryColumns - 5) / (dimensions?.aspectRatio ?? 1);
|
||||||
|
|
||||||
|
const mediaComponent = useMemo(() => {
|
||||||
|
switch (meme.memeType) {
|
||||||
|
case MEME_TYPE.IMAGE:
|
||||||
|
case MEME_TYPE.GIF:
|
||||||
|
case MEME_TYPE.VIDEO: {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
source={{ uri }}
|
||||||
|
style={[
|
||||||
|
memeMasonryItemStyles.image,
|
||||||
|
{
|
||||||
|
width: itemWidth,
|
||||||
|
height: itemHeight,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [itemHeight, itemWidth, meme.memeType, uri]);
|
||||||
|
|
||||||
if (!error && (loading || !dimensions)) return <></>;
|
if (!error && (loading || !dimensions)) return <></>;
|
||||||
|
|
||||||
@@ -51,25 +79,12 @@ const MemesMasonryItem = ({
|
|||||||
<MemeFail
|
<MemeFail
|
||||||
style={[
|
style={[
|
||||||
memeMasonryItemStyles.image,
|
memeMasonryItemStyles.image,
|
||||||
{
|
{ width: itemWidth, height: itemHeight },
|
||||||
width: (width * 0.92) / masonryColumns - 5,
|
|
||||||
height: (width * 0.92) / masonryColumns - 5,
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
iconSize={getFontAwesome5IconSize(masonryColumns)}
|
iconSize={getFontAwesome5IconSize(masonryColumns)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
mediaComponent
|
||||||
source={{ uri }}
|
|
||||||
style={[
|
|
||||||
memeMasonryItemStyles.image,
|
|
||||||
{
|
|
||||||
width: (width * 0.92) / masonryColumns - 5,
|
|
||||||
height:
|
|
||||||
((width * 0.92) / masonryColumns - 5) / dimensions.aspectRatio,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</TouchableHighlight>
|
</TouchableHighlight>
|
||||||
);
|
);
|
||||||
|
1
src/hooks/index.ts
Normal file
1
src/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as useMemeDimensions } from './useMemeDimensions';
|
56
src/hooks/useMemeDimensions.ts
Normal file
56
src/hooks/useMemeDimensions.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Image } from 'react-native';
|
||||||
|
import { Dimensions } from '../types';
|
||||||
|
|
||||||
|
const useMediaDimensions = (
|
||||||
|
uri?: string,
|
||||||
|
mimeType?: string,
|
||||||
|
onLoad?: (width: number, height: number, aspectRatio: number) => void,
|
||||||
|
onError?: (error: Error) => void,
|
||||||
|
) => {
|
||||||
|
const [dimensions, setDimensions] = useState<Dimensions | undefined>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | undefined>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getDimensions = () => {
|
||||||
|
if (!uri || !mimeType) return;
|
||||||
|
|
||||||
|
const mimeStart = mimeType.split('/')[0];
|
||||||
|
|
||||||
|
switch (mimeStart) {
|
||||||
|
case 'image':
|
||||||
|
case 'video': {
|
||||||
|
Image.getSize(
|
||||||
|
uri,
|
||||||
|
(width, height) => {
|
||||||
|
const aspectRatio = width / height;
|
||||||
|
setDimensions({ width, height, aspectRatio });
|
||||||
|
setLoading(false);
|
||||||
|
onLoad?.(width, height, aspectRatio);
|
||||||
|
},
|
||||||
|
(errorIn: string) => {
|
||||||
|
const errorOut = new Error(errorIn);
|
||||||
|
setError(errorOut);
|
||||||
|
setLoading(false);
|
||||||
|
onError?.(errorOut);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const errorOut = new Error(`Unknown mime type: ${mimeType}`);
|
||||||
|
setError(errorOut);
|
||||||
|
setLoading(false);
|
||||||
|
onError?.(errorOut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getDimensions();
|
||||||
|
}, [mimeType, onError, onLoad, uri]);
|
||||||
|
|
||||||
|
return { dimensions, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useMediaDimensions;
|
@@ -6,7 +6,13 @@ import { FlashList } from '@shopify/flash-list';
|
|||||||
import { Appbar, Portal, Snackbar } from 'react-native-paper';
|
import { Appbar, Portal, Snackbar } from 'react-native-paper';
|
||||||
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
import { useSafeAreaFrame } from 'react-native-safe-area-context';
|
||||||
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
||||||
import { RootStackParamList, ROUTE } from '../types';
|
import { useSelector } from 'react-redux';
|
||||||
|
import {
|
||||||
|
memesSortQuery,
|
||||||
|
RootStackParamList,
|
||||||
|
ROUTE,
|
||||||
|
SORT_DIRECTION,
|
||||||
|
} from '../types';
|
||||||
import { Meme } from '../database';
|
import { Meme } from '../database';
|
||||||
import { LoadingView, MemeViewItem } from '../components';
|
import { LoadingView, MemeViewItem } from '../components';
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +23,6 @@ import {
|
|||||||
multipleIdQuery,
|
multipleIdQuery,
|
||||||
shareMeme,
|
shareMeme,
|
||||||
} from '../utilities';
|
} from '../utilities';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { RootState } from '../state';
|
import { RootState } from '../state';
|
||||||
|
|
||||||
const memeViewStyles = StyleSheet.create({
|
const memeViewStyles = StyleSheet.create({
|
||||||
@@ -50,6 +55,10 @@ const MemeView = ({
|
|||||||
}: NativeStackScreenProps<RootStackParamList, ROUTE.MEME_VIEW>) => {
|
}: NativeStackScreenProps<RootStackParamList, ROUTE.MEME_VIEW>) => {
|
||||||
const { height, width } = useSafeAreaFrame();
|
const { height, width } = useSafeAreaFrame();
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
|
const sort = useSelector((state: RootState) => state.memes.sort);
|
||||||
|
const sortDirection = useSelector(
|
||||||
|
(state: RootState) => state.memes.sortDirection,
|
||||||
|
);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const storageUri = useSelector(
|
const storageUri = useSelector(
|
||||||
(state: RootState) => state.settings.storageUri,
|
(state: RootState) => state.settings.storageUri,
|
||||||
@@ -61,15 +70,20 @@ const MemeView = ({
|
|||||||
|
|
||||||
const flashListRef = useRef<FlashList<Meme>>(null);
|
const flashListRef = useRef<FlashList<Meme>>(null);
|
||||||
|
|
||||||
const [index, setIndex] = useState(route.params.index);
|
const index = useRef(route.params.index);
|
||||||
const memes = useQuery<Meme>(Meme.schema.name, collectionIn => {
|
const memes = useQuery<Meme>(Meme.schema.name, collectionIn => {
|
||||||
return collectionIn.filtered(multipleIdQuery(route.params.ids));
|
return collectionIn
|
||||||
|
.filtered(multipleIdQuery(route.params.ids))
|
||||||
|
.sorted(
|
||||||
|
memesSortQuery(sort),
|
||||||
|
sortDirection === SORT_DIRECTION.DESCENDING,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (memes.length === 0) navigation.goBack();
|
if (memes.length === 0) navigation.goBack();
|
||||||
if (index >= memes.length) {
|
if (index.current >= memes.length) {
|
||||||
setIndex(memes.length - 1);
|
index.current = memes.length - 1;
|
||||||
flashListRef.current?.scrollToIndex({ index: memes.length - 1 });
|
flashListRef.current?.scrollToIndex({ index: memes.length - 1 });
|
||||||
}
|
}
|
||||||
}, [index, memes.length, navigation]);
|
}, [index, memes.length, navigation]);
|
||||||
@@ -79,19 +93,19 @@ const MemeView = ({
|
|||||||
<Appbar.Header style={memeViewStyles.header}>
|
<Appbar.Header style={memeViewStyles.header}>
|
||||||
<Appbar.BackAction onPress={() => navigation.goBack()} />
|
<Appbar.BackAction onPress={() => navigation.goBack()} />
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */}
|
{/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */}
|
||||||
<Appbar.Content title={memes[index]?.title} />
|
<Appbar.Content title={memes[index.current]?.title} />
|
||||||
</Appbar.Header>
|
</Appbar.Header>
|
||||||
<FlashList
|
<FlashList
|
||||||
ref={flashListRef}
|
ref={flashListRef}
|
||||||
key={height}
|
key={height}
|
||||||
data={memes}
|
data={memes}
|
||||||
initialScrollIndex={index}
|
initialScrollIndex={index.current}
|
||||||
onScroll={event => {
|
onScroll={event => {
|
||||||
const newIndex = Math.round(
|
const newIndex = Math.round(
|
||||||
event.nativeEvent.contentOffset.x /
|
event.nativeEvent.contentOffset.x /
|
||||||
event.nativeEvent.layoutMeasurement.width,
|
event.nativeEvent.layoutMeasurement.width,
|
||||||
);
|
);
|
||||||
if (newIndex !== index) setIndex(newIndex);
|
if (newIndex !== index.current) index.current = newIndex;
|
||||||
}}
|
}}
|
||||||
estimatedItemSize={width}
|
estimatedItemSize={width}
|
||||||
estimatedListSize={{ height, width }}
|
estimatedListSize={{ height, width }}
|
||||||
@@ -111,14 +125,14 @@ const MemeView = ({
|
|||||||
<Appbar style={memeViewStyles.footer}>
|
<Appbar style={memeViewStyles.footer}>
|
||||||
<Appbar.Action
|
<Appbar.Action
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
icon={memes[index]?.isFavorite ? 'heart' : 'heart-outline'}
|
icon={memes[index.current]?.isFavorite ? 'heart' : 'heart-outline'}
|
||||||
onPress={() => favoriteMeme(realm, memes[index])}
|
onPress={() => favoriteMeme(realm, memes[index.current])}
|
||||||
disabled={isBlocked}
|
disabled={isBlocked}
|
||||||
/>
|
/>
|
||||||
<Appbar.Action
|
<Appbar.Action
|
||||||
icon="share"
|
icon="share"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
shareMeme(realm, storageUri, memes[index]).catch(() =>
|
shareMeme(realm, storageUri, memes[index.current]).catch(() =>
|
||||||
setSnackbarMessage('Failed to share meme!'),
|
setSnackbarMessage('Failed to share meme!'),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -127,7 +141,7 @@ const MemeView = ({
|
|||||||
<Appbar.Action
|
<Appbar.Action
|
||||||
icon="content-copy"
|
icon="content-copy"
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await copyMeme(realm, storageUri, memes[index])
|
await copyMeme(realm, storageUri, memes[index.current])
|
||||||
.then(() => setSnackbarMessage('Meme copied!'))
|
.then(() => setSnackbarMessage('Meme copied!'))
|
||||||
.catch(() => setSnackbarMessage('Failed to copy meme!'));
|
.catch(() => setSnackbarMessage('Failed to copy meme!'));
|
||||||
}}
|
}}
|
||||||
@@ -136,7 +150,7 @@ const MemeView = ({
|
|||||||
<Appbar.Action
|
<Appbar.Action
|
||||||
icon="pencil"
|
icon="pencil"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
editMeme(navigation, memes[index]);
|
editMeme(navigation, memes[index.current]);
|
||||||
}}
|
}}
|
||||||
disabled={isBlocked}
|
disabled={isBlocked}
|
||||||
/>
|
/>
|
||||||
@@ -144,7 +158,7 @@ const MemeView = ({
|
|||||||
icon="delete"
|
icon="delete"
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
setIsBlocked(true);
|
setIsBlocked(true);
|
||||||
await deleteMeme(realm, storageUri, memes[index]);
|
await deleteMeme(realm, storageUri, memes[index.current]);
|
||||||
setIsBlocked(false);
|
setIsBlocked(false);
|
||||||
}}
|
}}
|
||||||
disabled={isBlocked}
|
disabled={isBlocked}
|
||||||
|
@@ -98,7 +98,7 @@ const Memes = () => {
|
|||||||
[sort, sortDirection, favoritesOnly, filter, search],
|
[sort, sortDirection, favoritesOnly, filter, search],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [scrollOffset, setScrollOffset] = useState(0);
|
const previousOffset = useRef(0);
|
||||||
|
|
||||||
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
const currentOffset = event.nativeEvent.contentOffset.y;
|
const currentOffset = event.nativeEvent.contentOffset.y;
|
||||||
@@ -106,13 +106,13 @@ const Memes = () => {
|
|||||||
if (currentOffset <= 150) {
|
if (currentOffset <= 150) {
|
||||||
dispatch(setNavVisible(true));
|
dispatch(setNavVisible(true));
|
||||||
} else {
|
} else {
|
||||||
const diff = currentOffset - scrollOffset;
|
const diff = currentOffset - previousOffset.current;
|
||||||
if (Math.abs(diff) > 50) {
|
if (Math.abs(diff) > 35) {
|
||||||
dispatch(setNavVisible(diff < 0));
|
dispatch(setNavVisible(diff < 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setScrollOffset(currentOffset);
|
previousOffset.current = currentOffset;
|
||||||
};
|
};
|
||||||
|
|
||||||
const flashListRef = useRef<FlashList<Meme>>(null);
|
const flashListRef = useRef<FlashList<Meme>>(null);
|
||||||
@@ -120,7 +120,7 @@ const Memes = () => {
|
|||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
const handleBackPress = () => {
|
const handleBackPress = () => {
|
||||||
if (scrollOffset <= 0) return false;
|
if (previousOffset.current <= 0) return false;
|
||||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -129,7 +129,7 @@ const Memes = () => {
|
|||||||
|
|
||||||
return () =>
|
return () =>
|
||||||
BackHandler.removeEventListener('hardwareBackPress', handleBackPress);
|
BackHandler.removeEventListener('hardwareBackPress', handleBackPress);
|
||||||
}, [scrollOffset]),
|
}, []),
|
||||||
);
|
);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
|
@@ -71,7 +71,7 @@ const Tags = () => {
|
|||||||
[search, sort, sortDirection],
|
[search, sort, sortDirection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [scrollOffset, setScrollOffset] = useState(0);
|
const previousOffset = useRef(0);
|
||||||
|
|
||||||
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
const currentOffset = event.nativeEvent.contentOffset.y;
|
const currentOffset = event.nativeEvent.contentOffset.y;
|
||||||
@@ -79,11 +79,11 @@ const Tags = () => {
|
|||||||
if (currentOffset <= 150) {
|
if (currentOffset <= 150) {
|
||||||
dispatch(setNavVisible(true));
|
dispatch(setNavVisible(true));
|
||||||
} else {
|
} else {
|
||||||
const diff = currentOffset - scrollOffset;
|
const diff = currentOffset - previousOffset.current;
|
||||||
if (Math.abs(diff) > 50) dispatch(setNavVisible(diff < 0));
|
if (Math.abs(diff) > 50) dispatch(setNavVisible(diff < 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
setScrollOffset(currentOffset);
|
previousOffset.current = currentOffset;
|
||||||
};
|
};
|
||||||
|
|
||||||
const flashListRef = useRef<FlashList<Tag>>(null);
|
const flashListRef = useRef<FlashList<Tag>>(null);
|
||||||
@@ -91,7 +91,7 @@ const Tags = () => {
|
|||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
const handleBackPress = () => {
|
const handleBackPress = () => {
|
||||||
if (scrollOffset > 0) {
|
if (previousOffset.current > 0) {
|
||||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,7 @@ const Tags = () => {
|
|||||||
|
|
||||||
return () =>
|
return () =>
|
||||||
BackHandler.removeEventListener('hardwareBackPress', handleBackPress);
|
BackHandler.removeEventListener('hardwareBackPress', handleBackPress);
|
||||||
}, [scrollOffset]),
|
}, []),
|
||||||
);
|
);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
interface Dimensions {
|
interface Dimensions {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
aspectRatio: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { type Dimensions };
|
export { type Dimensions };
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import { FileSystem } from 'react-native-file-access';
|
import { FileSystem } from 'react-native-file-access';
|
||||||
import filetypemime from 'magic-bytes.js';
|
import filetypemime from 'magic-bytes.js';
|
||||||
|
import { lookup } from 'react-native-mime-types';
|
||||||
import { MEME_TYPE } from '../database';
|
import { MEME_TYPE } from '../database';
|
||||||
|
|
||||||
const allowedImageMimeTypes = [
|
const allowedImageMimeTypes = [
|
||||||
@@ -12,43 +13,52 @@ const allowedImageMimeTypes = [
|
|||||||
|
|
||||||
const allowedGifMimeTypes = ['image/gif'];
|
const allowedGifMimeTypes = ['image/gif'];
|
||||||
|
|
||||||
const allowedMimeTypes = [...allowedImageMimeTypes, ...allowedGifMimeTypes];
|
const allowedVideoMimeTypes = [
|
||||||
|
'video/av01',
|
||||||
|
'video/3gpp',
|
||||||
|
'video/avc',
|
||||||
|
'video/hevc',
|
||||||
|
'video/x-matroska',
|
||||||
|
'video/mp2t',
|
||||||
|
'video/mp4',
|
||||||
|
'video/mp42',
|
||||||
|
'video/mp43',
|
||||||
|
'video/mp4v-es',
|
||||||
|
'video/mpeg',
|
||||||
|
'video/mpeg2',
|
||||||
|
'video/x-vnd.on2.vp8',
|
||||||
|
'video/x-vnd.on2.vp9',
|
||||||
|
'video/webm',
|
||||||
|
];
|
||||||
|
|
||||||
|
const allowedMimeTypes = [
|
||||||
|
...allowedImageMimeTypes,
|
||||||
|
...allowedGifMimeTypes,
|
||||||
|
...allowedVideoMimeTypes,
|
||||||
|
];
|
||||||
|
|
||||||
const getMemeTypeFromMimeType = (mimeType: string): MEME_TYPE | undefined => {
|
const getMemeTypeFromMimeType = (mimeType: string): MEME_TYPE | undefined => {
|
||||||
switch (mimeType) {
|
if (!allowedMimeTypes.includes(mimeType)) return undefined;
|
||||||
case 'image/bmp':
|
|
||||||
case 'image/jpg':
|
const mimeStart = mimeType.split('/')[0];
|
||||||
case 'image/jpeg':
|
|
||||||
case 'image/png':
|
switch (mimeStart) {
|
||||||
case 'image/webp': {
|
case 'image': {
|
||||||
|
if (mimeType === 'image/gif') return MEME_TYPE.GIF;
|
||||||
return MEME_TYPE.IMAGE;
|
return MEME_TYPE.IMAGE;
|
||||||
}
|
}
|
||||||
case 'image/gif': {
|
case 'video': {
|
||||||
return MEME_TYPE.GIF;
|
return MEME_TYPE.VIDEO;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const guessMimeTypeFromExtension = (filename: string): string | undefined => {
|
const guessMimeTypeFromExtension = (filename: string): string | undefined => {
|
||||||
const extension = filename.split('.').pop()?.toLowerCase();
|
const extension = filename.split('.').pop()?.toLowerCase();
|
||||||
switch (extension) {
|
if (!extension) return undefined;
|
||||||
case 'bmp': {
|
const guessedMimeType = lookup(extension);
|
||||||
return 'image/bmp';
|
if (!guessedMimeType) return undefined;
|
||||||
}
|
return guessedMimeType;
|
||||||
case 'jpg':
|
|
||||||
case 'jpeg': {
|
|
||||||
return 'image/jpeg';
|
|
||||||
}
|
|
||||||
case 'png': {
|
|
||||||
return 'image/png';
|
|
||||||
}
|
|
||||||
case 'webp': {
|
|
||||||
return 'image/webp';
|
|
||||||
}
|
|
||||||
case 'gif': {
|
|
||||||
return 'image/gif';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const guessMimeTypeFromMagicBytes = async (
|
const guessMimeTypeFromMagicBytes = async (
|
||||||
@@ -66,10 +76,15 @@ const guessMimeType = async (
|
|||||||
): Promise<string | undefined> => {
|
): Promise<string | undefined> => {
|
||||||
if (hint && allowedMimeTypes.includes(hint)) return hint;
|
if (hint && allowedMimeTypes.includes(hint)) return hint;
|
||||||
|
|
||||||
const guessedMimeType = guessMimeTypeFromExtension(uri);
|
let guessedMimeType = guessMimeTypeFromExtension(uri);
|
||||||
if (guessedMimeType) return guessedMimeType;
|
if (guessedMimeType && allowedMimeTypes.includes(guessedMimeType)) {
|
||||||
|
return guessedMimeType;
|
||||||
|
}
|
||||||
|
|
||||||
return await guessMimeTypeFromMagicBytes(uri);
|
guessedMimeType = await guessMimeTypeFromMagicBytes(uri);
|
||||||
|
if (guessedMimeType && allowedMimeTypes.includes(guessedMimeType)) {
|
||||||
|
return guessedMimeType;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
Reference in New Issue
Block a user