Merge branch 'master' into feat/i18n

This commit is contained in:
Simon Caron
2025-01-05 15:06:44 -05:00
27 changed files with 142 additions and 56 deletions

View File

@@ -4,9 +4,7 @@ title: "[Bug]: "
labels: labels:
- ["❌ bug"] - ["❌ bug"]
projects: projects:
- ["fredrikburmester/5"] - ["streamyfin/3"]
assignees:
- fredrikburmester
body: body:
- type: textarea - type: textarea

View File

@@ -4,7 +4,8 @@ about: Suggest an idea for this project
title: '' title: ''
labels: '✨ enhancement' labels: '✨ enhancement'
assignees: '' assignees: ''
projects:
- streamyfin/3
--- ---
**Describe the solution you'd like** **Describe the solution you'd like**

View File

@@ -1,4 +1,4 @@
name: release name: Automatic Build and Deploy
on: on:
workflow_dispatch: workflow_dispatch:
@@ -27,8 +27,8 @@ jobs:
pods-path: "ios/Podfile" pods-path: "ios/Podfile"
configuration: Release configuration: Release
# Change later to app-store if wanted # Change later to app-store if wanted
#export-method: app-store export-method: appstore
export-method: ad-hoc #export-method: ad-hoc
workspace-path: "ios/Streamyfin.xcodeproj/project.xcworkspace/" workspace-path: "ios/Streamyfin.xcodeproj/project.xcworkspace/"
project-path: "ios/Streamyfin.xcodeproj" project-path: "ios/Streamyfin.xcodeproj"
scheme: Streamyfin scheme: Streamyfin

View File

@@ -8,7 +8,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
<img width=150 src="./assets/images/screenshots/screenshot1.png" /> <img width=150 src="./assets/images/screenshots/screenshot1.png" />
<img width=150 src="./assets/images/screenshots/screenshot3.png" /> <img width=150 src="./assets/images/screenshots/screenshot3.png" />
<img width=150 src="./assets/images/screenshots/screenshot2.png" /> <img width=150 src="./assets/images/screenshots/screenshot2.png" />
<img width=150 src="./assets/images/jellyseerr.PNG"/>
</div> </div>
## 🌟 Features ## 🌟 Features

View File

@@ -14,7 +14,7 @@ import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { UserInfo } from "@/components/settings/UserInfo"; import { UserInfo } from "@/components/settings/UserInfo";
import { useJellyfin } from "@/providers/JellyfinProvider"; import { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log"; import { clearLogs } from "@/utils/log";
import * as Haptics from "expo-haptics"; import { useHaptic } from "@/hooks/useHaptic";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -25,10 +25,11 @@ export default function settings() {
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { logout } = useJellyfin(); const { logout } = useJellyfin();
const successHapticFeedback = useHaptic("success");
const onClearLogsClicked = async () => { const onClearLogsClicked = async () => {
clearLogs(); clearLogs();
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); successHapticFeedback();
}; };
const navigation = useNavigation(); const navigation = useNavigation();

View File

@@ -27,7 +27,7 @@ import {
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics"; import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useGlobalSearchParams } from "expo-router"; import { useFocusEffect, useGlobalSearchParams } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { import React, {
@@ -70,9 +70,11 @@ export default function page() {
const { getDownloadedItem } = useDownload(); const { getDownloadedItem } = useDownload();
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light");
const setShowControls = useCallback((show: boolean) => { const setShowControls = useCallback((show: boolean) => {
_setShowControls(show); _setShowControls(show);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
}, []); }, []);
const { const {
@@ -177,7 +179,7 @@ export default function page() {
const togglePlay = useCallback(async () => { const togglePlay = useCallback(async () => {
if (!api) return; if (!api) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();

View File

@@ -17,7 +17,7 @@ import {
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics"; import { useHaptic } from "@/hooks/useHaptic";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useFocusEffect, useLocalSearchParams } from "expo-router"; import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
@@ -47,6 +47,8 @@ export default function page() {
const isSeeking = useSharedValue(false); const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0); const cacheProgress = useSharedValue(0);
const lightHapticFeedback = useHaptic("light");
const { const {
itemId, itemId,
audioIndex: audioIndexStr, audioIndex: audioIndexStr,
@@ -126,7 +128,7 @@ export default function page() {
const togglePlay = useCallback( const togglePlay = useCallback(
async (ticks: number) => { async (ticks: number) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
if (isPlaying) { if (isPlaying) {
videoRef.current?.pause(); videoRef.current?.pause();
await getPlaystateApi(api!).onPlaybackProgress({ await getPlaystateApi(api!).onPlaybackProgress({

View File

@@ -20,7 +20,7 @@ import {
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics"; import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useLocalSearchParams } from "expo-router"; import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { import React, {
@@ -50,6 +50,7 @@ const Player = () => {
const firstTime = useRef(true); const firstTime = useRef(true);
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light");
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
@@ -60,7 +61,7 @@ const Player = () => {
const setShowControls = useCallback((show: boolean) => { const setShowControls = useCallback((show: boolean) => {
_setShowControls(show); _setShowControls(show);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
}, []); }, []);
const progress = useSharedValue(0); const progress = useSharedValue(0);
@@ -169,7 +170,7 @@ const Player = () => {
const videoSource = useVideoSource(item, api, poster, stream?.url); const videoSource = useVideoSource(item, api, poster, stream?.url);
const togglePlay = useCallback(async () => { const togglePlay = useCallback(async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
if (isPlaying) { if (isPlaying) {
videoRef.current?.pause(); videoRef.current?.pause();
await getPlaystateApi(api!).onPlaybackProgress({ await getPlaystateApi(api!).onPlaybackProgress({

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -1,4 +1,4 @@
import * as Haptics from "expo-haptics"; import { useHaptic } from "@/hooks/useHaptic";
import React, { PropsWithChildren, ReactNode, useMemo } from "react"; import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import { Text, TouchableOpacity, View } from "react-native"; import { Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
@@ -43,6 +43,8 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
} }
}, [color]); }, [color]);
const lightHapticFeedback = useHaptic("light");
return ( return (
<TouchableOpacity <TouchableOpacity
className={` className={`
@@ -54,7 +56,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
onPress={() => { onPress={() => {
if (!loading && !disabled && onPress) { if (!loading && !disabled && onPress) {
onPress(); onPress();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
} }
}} }}
disabled={disabled || loading} disabled={disabled || loading}

View File

@@ -32,8 +32,8 @@ import Animated, {
import { Button } from "./Button"; import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent"; import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast"; import { chromecastProfile } from "@/utils/profiles/chromecast";
import * as Haptics from "expo-haptics";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends React.ComponentProps<typeof Button> { interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto; item: BaseItemDto;
@@ -66,6 +66,7 @@ export const PlayButton: React.FC<Props> = ({
const widthProgress = useSharedValue(0); const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings(); const [settings] = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback( const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => { (q: string, bitrateValue: number | undefined) => {
@@ -81,7 +82,7 @@ export const PlayButton: React.FC<Props> = ({
const onPress = useCallback(async () => { const onPress = useCallback(async () => {
if (!item) return; if (!item) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item.Id!, itemId: item.Id!,

View File

@@ -6,7 +6,7 @@ import {
TouchableOpacity, TouchableOpacity,
TouchableOpacityProps, TouchableOpacityProps,
} from "react-native"; } from "react-native";
import * as Haptics from "expo-haptics"; import { useHaptic } from "@/hooks/useHaptic";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
onPress?: () => void; onPress?: () => void;
@@ -29,10 +29,11 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
}) => { }) => {
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9"; const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : ""; const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
const lightHapticFeedback = useHaptic("light");
const handlePress = () => { const handlePress = () => {
if (hapticFeedback) { if (hapticFeedback) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
} }
onPress?.(); onPress?.();
}; };

View File

@@ -1,5 +1,5 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as Haptics from "expo-haptics"; import { useHaptic } from "@/hooks/useHaptic";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native"; import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import { import {
@@ -26,6 +26,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
const { deleteFile } = useDownload(); const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener(); const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const successHapticFeedback = useHaptic("success");
const base64Image = useMemo(() => { const base64Image = useMemo(() => {
return storage.getString(item.Id!); return storage.getString(item.Id!);
@@ -41,7 +42,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
const handleDeleteFile = useCallback(() => { const handleDeleteFile = useCallback(() => {
if (item.Id) { if (item.Id) {
deleteFile(item.Id); deleteFile(item.Id);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); successHapticFeedback();
} }
}, [deleteFile, item.Id]); }, [deleteFile, item.Id]);

View File

@@ -3,7 +3,7 @@ import {
useActionSheet, useActionSheet,
} from "@expo/react-native-action-sheet"; } from "@expo/react-native-action-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as Haptics from "expo-haptics"; import { useHaptic } from "@/hooks/useHaptic";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
@@ -28,6 +28,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { deleteFile } = useDownload(); const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener(); const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const successHapticFeedback = useHaptic("success");
const handleOpenFile = useCallback(() => { const handleOpenFile = useCallback(() => {
openFile(item); openFile(item);
@@ -43,7 +44,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const handleDeleteFile = useCallback(() => { const handleDeleteFile = useCallback(() => {
if (item.Id) { if (item.Id) {
deleteFile(item.Id); deleteFile(item.Id);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); successHapticFeedback();
} }
}, [deleteFile, item.Id]); }, [deleteFile, item.Id]);

View File

@@ -22,7 +22,7 @@ import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
import { Loader } from "../Loader"; import { Loader } from "../Loader";
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { useRouter, useSegments } from "expo-router"; import { useRouter, useSegments } from "expo-router";
import * as Haptics from "expo-haptics"; import { useHaptic } from "@/hooks/useHaptic";
interface Props extends ViewProps {} interface Props extends ViewProps {}
@@ -128,6 +128,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const router = useRouter(); const router = useRouter();
const screenWidth = Dimensions.get("screen").width; const screenWidth = Dimensions.get("screen").width;
const lightHapticFeedback = useHaptic("light");
const uri = useMemo(() => { const uri = useMemo(() => {
if (!api) return null; if (!api) return null;
@@ -153,7 +154,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const handleRoute = useCallback(() => { const handleRoute = useCallback(() => {
if (!from) return; if (!from) return;
const url = itemRouter(item, from); const url = itemRouter(item, from);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
// @ts-ignore // @ts-ignore
if (url) router.push(url); if (url) router.push(url);
}, [item, from]); }, [item, from]);

View File

@@ -181,6 +181,15 @@ export const OtherSettings: React.FC = () => {
} }
/> />
</ListItem> </ListItem>
<ListItem title="Disable Haptic Feedback">
<Switch
value={settings.disableHapticFeedback}
onValueChange={(value) =>
updateSettings({ disableHapticFeedback: value })
}
/>
</ListItem>
</ListGroup> </ListGroup>
); );
}; };

View File

@@ -7,8 +7,8 @@ import {
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import * as Haptics from "expo-haptics";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useRef, useState } from "react"; import React, { useCallback, useRef, useState } from "react";
import { Alert, View, ViewProps } from "react-native"; import { Alert, View, ViewProps } from "react-native";
@@ -24,6 +24,8 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [quickConnectCode, setQuickConnectCode] = useState<string>(); const [quickConnectCode, setQuickConnectCode] = useState<string>();
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const { t } = useTranslation(); const { t } = useTranslation();
@@ -46,16 +48,16 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
userId: user?.Id, userId: user?.Id,
}); });
if (res.status === 200) { if (res.status === 200) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); successHapticFeedback();
Alert.alert(t("home.settings.quick_connect.success"), t("home.settings.quick_connect.quick_connect_autorized")); Alert.alert(t("home.settings.quick_connect.success"), t("home.settings.quick_connect.quick_connect_autorized"));
setQuickConnectCode(undefined); setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close(); bottomSheetModalRef?.current?.close();
} else { } else {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); errorHapticFeedback();
Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code")); Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
} }
} catch (e) { } catch (e) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); errorHapticFeedback();
Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code")); Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
} }
} }

View File

@@ -4,7 +4,7 @@ import { useDownload } from "@/providers/DownloadProvider";
import { clearLogs } from "@/utils/log"; import { clearLogs } from "@/utils/log";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import * as Haptics from "expo-haptics"; import { useHaptic } from "@/hooks/useHaptic";
import { View } from "react-native"; import { View } from "react-native";
import * as Progress from "react-native-progress"; import * as Progress from "react-native-progress";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
@@ -15,6 +15,8 @@ import { useTranslation } from "react-i18next";
export const StorageSettings = () => { export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload(); const { deleteAllFiles, appSizeUsage } = useDownload();
const { t } = useTranslation(); const { t } = useTranslation();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const { data: size, isLoading: appSizeLoading } = useQuery({ const { data: size, isLoading: appSizeLoading } = useQuery({
queryKey: ["appSize", appSizeUsage], queryKey: ["appSize", appSizeUsage],
@@ -31,9 +33,9 @@ export const StorageSettings = () => {
const onDeleteClicked = async () => { const onDeleteClicked = async () => {
try { try {
await deleteAllFiles(); await deleteAllFiles();
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); successHapticFeedback();
} catch (e) { } catch (e) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files")); toast.error(t("home.settings.toasts.error_deleting_files"));
} }
}; };

View File

@@ -29,7 +29,7 @@ import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import * as Haptics from "expo-haptics"; import { useHaptic } from "@/hooks/useHaptic";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@@ -157,10 +157,12 @@ export const Controls: React.FC<Props> = ({
isVlc isVlc
); );
const lightHapticFeedback = useHaptic("light");
const goToPreviousItem = useCallback(() => { const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return; if (!previousItem || !settings) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
const previousIndexes: previousIndexes = { const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
@@ -198,7 +200,7 @@ export const Controls: React.FC<Props> = ({
const goToNextItem = useCallback(() => { const goToNextItem = useCallback(() => {
if (!nextItem || !settings) return; if (!nextItem || !settings) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
const previousIndexes: previousIndexes = { const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
@@ -326,7 +328,7 @@ export const Controls: React.FC<Props> = ({
const handleSkipBackward = useCallback(async () => { const handleSkipBackward = useCallback(async () => {
if (!settings?.rewindSkipTime) return; if (!settings?.rewindSkipTime) return;
wasPlayingRef.current = isPlaying; wasPlayingRef.current = isPlaying;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
try { try {
const curr = progress.value; const curr = progress.value;
if (curr !== undefined) { if (curr !== undefined) {
@@ -344,7 +346,7 @@ export const Controls: React.FC<Props> = ({
const handleSkipForward = useCallback(async () => { const handleSkipForward = useCallback(async () => {
if (!settings?.forwardSkipTime) return; if (!settings?.forwardSkipTime) return;
wasPlayingRef.current = isPlaying; wasPlayingRef.current = isPlaying;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
try { try {
const curr = progress.value; const curr = progress.value;
if (curr !== undefined) { if (curr !== undefined) {
@@ -361,7 +363,7 @@ export const Controls: React.FC<Props> = ({
const toggleIgnoreSafeAreas = useCallback(() => { const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev); setIgnoreSafeAreas((prev) => !prev);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
}, []); }, []);
const memoizedRenderBubble = useCallback(() => { const memoizedRenderBubble = useCallback(() => {
@@ -440,7 +442,7 @@ export const Controls: React.FC<Props> = ({
const gotoItem = await getItemById(api, itemId); const gotoItem = await getItemById(api, itemId);
if (!settings || !gotoItem) return; if (!settings || !gotoItem) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
const previousIndexes: previousIndexes = { const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined, subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
@@ -584,7 +586,7 @@ export const Controls: React.FC<Props> = ({
)} )}
<TouchableOpacity <TouchableOpacity
onPress={async () => { onPress={async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
router.back(); router.back();
}} }}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2" className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"

View File

@@ -5,7 +5,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import { msToSeconds, secondsToMs } from "@/utils/time"; import { msToSeconds, secondsToMs } from "@/utils/time";
import * as Haptics from "expo-haptics"; import { useHaptic } from "./useHaptic";
interface CreditTimestamps { interface CreditTimestamps {
Introduction: { Introduction: {
@@ -29,6 +29,7 @@ export const useCreditSkipper = (
) => { ) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false); const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
const lightHapticFeedback = useHaptic("light");
if (isVlc) { if (isVlc) {
currentTime = msToSeconds(currentTime); currentTime = msToSeconds(currentTime);
@@ -79,7 +80,7 @@ export const useCreditSkipper = (
if (!creditTimestamps) return; if (!creditTimestamps) return;
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`); console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
try { try {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
wrappedSeek(creditTimestamps.Credits.End); wrappedSeek(creditTimestamps.Credits.End);
setTimeout(() => { setTimeout(() => {
play(); play();

View File

@@ -6,7 +6,7 @@ import {
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { useMemo } from "react"; import { useMemo } from "react";
// Used only for intial play settings. // Used only for initial play settings.
const useDefaultPlaySettings = ( const useDefaultPlaySettings = (
item: BaseItemDto, item: BaseItemDto,
settings: Settings | null settings: Settings | null

54
hooks/useHaptic.ts Normal file
View File

@@ -0,0 +1,54 @@
import { useCallback, useMemo } from "react";
import { Platform } from "react-native";
import * as Haptics from "expo-haptics";
import { useSettings } from "@/utils/atoms/settings";
export type HapticFeedbackType =
| "light"
| "medium"
| "heavy"
| "selection"
| "success"
| "warning"
| "error";
export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
const [settings] = useSettings();
const createHapticHandler = useCallback(
(type: Haptics.ImpactFeedbackStyle) => {
return Platform.OS === "web" ? () => {} : () => Haptics.impactAsync(type);
},
[]
);
const createNotificationFeedback = useCallback(
(type: Haptics.NotificationFeedbackType) => {
return Platform.OS === "web"
? () => {}
: () => Haptics.notificationAsync(type);
},
[]
);
const hapticHandlers = useMemo(
() => ({
light: createHapticHandler(Haptics.ImpactFeedbackStyle.Light),
medium: createHapticHandler(Haptics.ImpactFeedbackStyle.Medium),
heavy: createHapticHandler(Haptics.ImpactFeedbackStyle.Heavy),
selection: Platform.OS === "web" ? () => {} : Haptics.selectionAsync,
success: createNotificationFeedback(
Haptics.NotificationFeedbackType.Success
),
warning: createNotificationFeedback(
Haptics.NotificationFeedbackType.Warning
),
error: createNotificationFeedback(Haptics.NotificationFeedbackType.Error),
}),
[createHapticHandler, createNotificationFeedback]
);
if (settings?.disableHapticFeedback) {
return () => {};
}
return hapticHandlers[feedbackType];
};

View File

@@ -5,7 +5,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin"; import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import { msToSeconds, secondsToMs } from "@/utils/time"; import { msToSeconds, secondsToMs } from "@/utils/time";
import * as Haptics from "expo-haptics"; import { useHaptic } from "./useHaptic";
interface IntroTimestamps { interface IntroTimestamps {
EpisodeId: string; EpisodeId: string;
@@ -33,6 +33,7 @@ export const useIntroSkipper = (
if (isVlc) { if (isVlc) {
currentTime = msToSeconds(currentTime); currentTime = msToSeconds(currentTime);
} }
const lightHapticFeedback = useHaptic("light");
const wrappedSeek = (seconds: number) => { const wrappedSeek = (seconds: number) => {
if (isVlc) { if (isVlc) {
@@ -78,7 +79,7 @@ export const useIntroSkipper = (
const skipIntro = useCallback(() => { const skipIntro = useCallback(() => {
if (!introTimestamps) return; if (!introTimestamps) return;
try { try {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
wrappedSeek(introTimestamps.IntroEnd); wrappedSeek(introTimestamps.IntroEnd);
setTimeout(() => { setTimeout(() => {
play(); play();

View File

@@ -3,13 +3,14 @@ import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed"; import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import * as Haptics from "expo-haptics"; import { useHaptic } from "./useHaptic";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
export const useMarkAsPlayed = (item: BaseItemDto) => { export const useMarkAsPlayed = (item: BaseItemDto) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const lightHapticFeedback = useHaptic("light");
const invalidateQueries = () => { const invalidateQueries = () => {
const queriesToInvalidate = [ const queriesToInvalidate = [
@@ -29,7 +30,7 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
}; };
const markAsPlayedStatus = async (played: boolean) => { const markAsPlayedStatus = async (played: boolean) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); lightHapticFeedback();
// Optimistic update // Optimistic update
queryClient.setQueryData( queryClient.setQueryData(

View File

@@ -48,7 +48,7 @@ import useImageStorage from "@/hooks/useImageStorage";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import useDownloadHelper from "@/utils/download"; import useDownloadHelper from "@/utils/download";
import { FileInfo } from "expo-file-system"; import { FileInfo } from "expo-file-system";
import * as Haptics from "expo-haptics"; import { useHaptic } from "@/hooks/useHaptic";
import * as Application from "expo-application"; import * as Application from "expo-application";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -80,6 +80,8 @@ function useDownloadProvider() {
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom); const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
const successHapticFeedback = useHaptic("success");
const authHeader = useMemo(() => { const authHeader = useMemo(() => {
return api?.accessToken; return api?.accessToken;
}, [api]); }, [api]);
@@ -534,9 +536,7 @@ function useDownloadProvider() {
if (i.Id) return deleteFile(i.Id); if (i.Id) return deleteFile(i.Id);
return; return;
}) })
).then(() => ).then(() => successHapticFeedback());
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
);
}; };
const cleanCacheDirectory = async () => { const cleanCacheDirectory = async () => {

View File

View File

@@ -86,6 +86,7 @@ export type Settings = {
downloadMethod: "optimized" | "remux"; downloadMethod: "optimized" | "remux";
autoDownload: boolean; autoDownload: boolean;
showCustomMenuLinks: boolean; showCustomMenuLinks: boolean;
disableHapticFeedback: boolean;
subtitleSize: number; subtitleSize: number;
remuxConcurrentLimit: 1 | 2 | 3 | 4; remuxConcurrentLimit: 1 | 2 | 3 | 4;
safeAreaInControlsEnabled: boolean; safeAreaInControlsEnabled: boolean;
@@ -125,6 +126,7 @@ const loadSettings = (): Settings => {
downloadMethod: "remux", downloadMethod: "remux",
autoDownload: false, autoDownload: false,
showCustomMenuLinks: false, showCustomMenuLinks: false,
disableHapticFeedback: false,
subtitleSize: Platform.OS === "ios" ? 60 : 100, subtitleSize: Platform.OS === "ios" ? 60 : 100,
remuxConcurrentLimit: 1, remuxConcurrentLimit: 1,
safeAreaInControlsEnabled: true, safeAreaInControlsEnabled: true,