Compare commits

...

2 Commits

Author SHA1 Message Date
Fredrik Burmester
752cb1cdc6 wip 2024-08-18 17:10:31 +02:00
Fredrik Burmester
21c1221138 chore 2024-08-18 13:35:23 +02:00
14 changed files with 204 additions and 42 deletions

View File

@@ -26,6 +26,10 @@ Streamyfin includes some exciting experimental features like media downloading a
Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
## Roadmap for V1
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests.
## Get it now
<div style="display:flex;">
@@ -108,7 +112,6 @@ If you have questions or need support, feel free to reach out:
- GitHub Issues: Report bugs or request features here.
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
-
## Support

View File

@@ -30,7 +30,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 17,
"versionCode": 18,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png"
},
@@ -96,7 +96,8 @@
{
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
}
]
],
"expo-localization"
],
"experiments": {
"typedRoutes": true

View File

@@ -1,15 +1,15 @@
import { router, Tabs } from "expo-router";
import React, { useEffect } from "react";
import * as NavigationBar from "expo-navigation-bar";
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
import { Colors } from "@/constants/Colors";
import { Platform, TouchableOpacity, View } from "react-native";
import { Feather } from "@expo/vector-icons";
import { Chromecast } from "@/components/Chromecast";
import { BlurView } from "expo-blur";
import { StyleSheet } from "react-native";
import * as NavigationBar from "expo-navigation-bar";
import { Tabs } from "expo-router";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet } from "react-native";
export default function TabLayout() {
const { t } = useTranslation();
useEffect(() => {
if (Platform.OS === "android") {
NavigationBar.setBackgroundColorAsync("#121212");
@@ -53,7 +53,7 @@ export default function TabLayout() {
name="home"
options={{
headerShown: false,
title: "Home",
title: t("tabs.home"),
tabBarIcon: ({ color, focused }) => (
<TabBarIcon
name={focused ? "home" : "home-outline"}
@@ -66,7 +66,7 @@ export default function TabLayout() {
name="search"
options={{
headerShown: false,
title: "Search",
title: t("tabs.search"),
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? "search" : "search"} color={color} />
),
@@ -76,7 +76,7 @@ export default function TabLayout() {
name="library"
options={{
headerShown: false,
title: "Library",
title: t("tabs.library"),
tabBarIcon: ({ color, focused }) => (
<TabBarIcon
name={focused ? "apps" : "apps-outline"}

View File

@@ -1,11 +1,13 @@
import { Chromecast } from "@/components/Chromecast";
import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { TouchableOpacity } from "react-native";
export default function IndexLayout() {
const router = useRouter();
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
@@ -13,7 +15,7 @@ export default function IndexLayout() {
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: "Home",
headerTitle: t("home.home"),
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,

View File

@@ -20,12 +20,15 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { RefreshControl, ScrollView, View } from "react-native";
export default function index() {
const router = useRouter();
const queryClient = useQueryClient();
const { i18n, t } = useTranslation();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -216,9 +219,9 @@ export default function index() {
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">No Internet</Text>
<Text className="text-3xl font-bold mb-2">{t("home.noInternet")}</Text>
<Text className="text-center opacity-70">
No worries, you can still watch{"\n"}downloaded content.
{t("home.noInternetMessage")}
</Text>
<View className="mt-4">
<Button
@@ -229,7 +232,7 @@ export default function index() {
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
Go to downloads
{t("home.goToDownloads")}
</Button>
</View>
</View>
@@ -239,10 +242,8 @@ export default function index() {
if (isError)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">Oops!</Text>
<Text className="text-center opacity-70">
Something went wrong.{"\n"}Please log out and in again.
</Text>
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
<Text className="text-center opacity-70">{t("home.errorMessage")}</Text>
</View>
);
@@ -265,14 +266,14 @@ export default function index() {
<LargeMovieCarousel />
<ScrollingCollectionList
title="Continue Watching"
title={t("home.continueWatching")}
data={data}
loading={isLoading}
orientation="horizontal"
/>
<ScrollingCollectionList
title="Next Up"
title={t("home.nextUp")}
data={nextUpData}
loading={isLoadingNextUp}
orientation="horizontal"
@@ -283,19 +284,19 @@ export default function index() {
))}
<ScrollingCollectionList
title="Recently Added in Movies"
title={t("home.recentlyAddedMovies")}
data={recentlyAddedInMovies}
loading={isLoadingRecentlyAddedMovies}
/>
<ScrollingCollectionList
title="Recently Added in TV-Shows"
title={t("home.recentlyAddedTVShows")}
data={recentlyAddedInTVShows}
loading={isLoadingRecentlyAddedTVShows}
/>
<ScrollingCollectionList
title="Suggestions"
title={t("home.suggestions")}
data={suggestions}
loading={isLoadingSuggestions}
orientation="horizontal"

View File

@@ -17,6 +17,9 @@ import { useKeepAwake } from "expo-keep-awake";
import { useSettings } from "@/utils/atoms/settings";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { I18nextProvider, useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { getLocales } from "expo-localization";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
@@ -42,7 +45,9 @@ export default function RootLayout() {
return (
<JotaiProvider>
<Layout />
<I18nextProvider i18n={i18n}>
<Layout />
</I18nextProvider>
</JotaiProvider>
);
}
@@ -52,6 +57,8 @@ function Layout() {
useKeepAwake();
const { i18n } = useTranslation();
const queryClientRef = useRef<QueryClient>(
new QueryClient({
defaultOptions: {
@@ -75,6 +82,12 @@ function Layout() {
);
}, [settings]);
useEffect(() => {
i18n.changeLanguage(
settings?.preferedLanguage || getLocales()[0].languageCode || "en"
);
}, [settings]);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClientRef.current}>

View File

@@ -1,11 +1,13 @@
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { AxiosError } from "axios";
import { useAtom } from "jotai";
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
KeyboardAvoidingView,
@@ -21,6 +23,7 @@ const CredentialsSchema = z.object({
});
const Login: React.FC = () => {
const { t, i18n } = useTranslation();
const { setServer, login, removeServer } = useJellyfin();
const [api] = useAtom(apiAtom);
@@ -72,7 +75,7 @@ const Login: React.FC = () => {
<View className="mb-4">
<Text className="text-3xl font-bold mb-2">Streamyfin</Text>
<Text className="text-neutral-500 mb-2">
Server: {api.basePath}
{t("server.server_label", { serverURL: api.basePath })}
</Text>
<Button
color="black"
@@ -89,17 +92,17 @@ const Login: React.FC = () => {
/>
}
>
Change server
{t("server.change_server")}
</Button>
</View>
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold">Log in</Text>
<Text className="text-2xl font-bold">{t("login.login")}</Text>
<Text className="text-neutral-500">
Log in to any user account
{t("login.login_subtitle")}
</Text>
<Input
placeholder="Username"
placeholder={t("login.username_placeholder")}
onChangeText={(text) =>
setCredentials({ ...credentials, username: text })
}
@@ -116,7 +119,7 @@ const Login: React.FC = () => {
<Input
className="mb-2"
placeholder="Password"
placeholder={t("login.password_placeholder")}
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
@@ -139,7 +142,7 @@ const Login: React.FC = () => {
loading={loading}
className="mt-auto mb-2"
>
Log in
{t("login.login_button")}
</Button>
</View>
</KeyboardAvoidingView>
@@ -158,10 +161,10 @@ const Login: React.FC = () => {
<View className="flex flex-col gap-y-2">
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
Connect to your Jellyfin server
{t("server.connect_to_server")}
</Text>
<Input
placeholder="Server URL"
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType="url"
@@ -170,12 +173,11 @@ const Login: React.FC = () => {
textContentType="URL"
maxLength={500}
/>
<Text className="opacity-30">
Server URL requires http or https
</Text>
<Text className="opacity-30">{t("server.server_url_hint")}</Text>
<LanguageSwitcher />
</View>
<Button onPress={() => handleConnect(serverURL)} className="mb-2">
Connect
{t("server.connect_button")}
</Button>
</View>
</KeyboardAvoidingView>

BIN
bun.lockb

Binary file not shown.

View File

@@ -0,0 +1,36 @@
import { Text } from "@/components/common/Text";
import { useSettings } from "@/utils/atoms/settings";
import { getLocales } from "expo-localization";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, ViewProps } from "react-native";
interface Props extends ViewProps {}
export const LanguageSwitcher: React.FC<Props> = ({ ...props }) => {
const { i18n } = useTranslation();
const lngs = ["en", "sv"];
const [settings, updateSettings] = useSettings();
return (
<View className="flex flex-row space-x-2" {...props}>
{lngs.map((l) => (
<TouchableOpacity
key={l}
onPress={() => {
i18n.changeLanguage(l);
updateSettings({ preferedLanguage: l });
}}
>
<Text
className={`uppercase ${
i18n.language === l ? "text-blue-500" : "text-gray-400 underline"
}`}
>
{l}
</Text>
</TouchableOpacity>
))}
</View>
);
};

22
i18n.ts Normal file
View File

@@ -0,0 +1,22 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "./translations/en.json";
import sv from "./translations/sv.json";
import { getLocales } from "expo-localization";
i18n.use(initReactI18next).init({
compatibilityJSON: "v3",
resources: {
en: { translation: en },
sv: { translation: sv },
},
lng: getLocales()[0].languageCode || "en",
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
});
export default i18n;

View File

@@ -30,7 +30,7 @@
"@types/lodash": "^4.17.7",
"@types/uuid": "^10.0.0",
"axios": "^1.7.3",
"expo": "~51.0.27",
"expo": "~51.0.28",
"expo-blur": "~13.0.2",
"expo-build-properties": "~0.12.5",
"expo-constants": "~16.0.2",
@@ -41,8 +41,9 @@
"expo-image": "~1.12.13",
"expo-keep-awake": "~13.0.2",
"expo-linking": "~6.3.1",
"expo-localization": "~15.0.3",
"expo-navigation-bar": "~3.0.7",
"expo-router": "~3.5.21",
"expo-router": "~3.5.23",
"expo-screen-orientation": "~7.0.5",
"expo-sensors": "~13.0.9",
"expo-splash-screen": "~0.27.5",
@@ -51,11 +52,13 @@
"expo-updates": "~0.25.22",
"expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2",
"i18next": "^23.13.0",
"jotai": "^2.9.1",
"lodash": "^4.17.21",
"nativewind": "^2.0.11",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-i18next": "^15.0.1",
"react-native": "0.74.5",
"react-native-circular-progress": "^1.4.0",
"react-native-compressor": "^1.8.25",

38
translations/en.json Normal file
View File

@@ -0,0 +1,38 @@
{
"login": {
"username_required": "Username is required",
"error_title": "Error",
"url_error_message": "URL needs to start with http or https.",
"login": "Log in",
"login_subtitle": "Log in to any user account",
"username_placeholder": "Username",
"password_placeholder": "Password",
"login_button": "Log in"
},
"server": {
"server_label": "Server: {{serverURL}}",
"change_server": "Change server",
"connect_to_server": "Connect to your Jellyfin server",
"server_url_placeholder": "Server URL",
"server_url_hint": "Server URL requires http or https",
"connect_button": "Connect"
},
"home": {
"home": "Home",
"noInternet": "No Internet",
"noInternetMessage": "No worries, you can still watch\ndownloaded content.",
"goToDownloads": "Go to downloads",
"oops": "Oops!",
"errorMessage": "Something went wrong.\nPlease log out and in again.",
"continueWatching": "Continue Watching",
"nextUp": "Next Up",
"recentlyAddedMovies": "Recently Added in Movies",
"recentlyAddedTVShows": "Recently Added in TV-Shows",
"suggestions": "Suggestions"
},
"tabs": {
"home": "Home",
"search": "Search",
"library": "Library"
}
}

38
translations/sv.json Normal file
View File

@@ -0,0 +1,38 @@
{
"login": {
"username_required": "Användarnamn krävs",
"error_title": "Fel",
"url_error_message": "URL måste börja med http eller https.",
"login_title": "Logga in",
"login_subtitle": "Logga in på ett användarkonto",
"username_placeholder": "Användarnamn",
"password_placeholder": "Lösenord",
"login_button": "Logga in"
},
"server": {
"server_label": "Server: {{serverURL}}",
"change_server": "Byt server",
"connect_to_server": "Anslut till din Jellyfin-server",
"server_url_placeholder": "Server URL",
"server_url_hint": "Server URL kräver http eller https",
"connect_button": "Anslut"
},
"home": {
"home": "Hem",
"noInternet": "Ingen Internet",
"noInternetMessage": "Ingen fara, du kan fortfarande titta\npå nedladdat innehåll.",
"goToDownloads": "Gå till nedladdningar",
"oops": "Hoppsan!",
"errorMessage": "Något gick fel.\nLogga ut och in igen.",
"continueWatching": "Fortsätt titta",
"nextUp": "Nästa upp",
"recentlyAddedMovies": "Nyligen tillagt i Filmer",
"recentlyAddedTVShows": "Nyligen tillagt i TV-Serier",
"suggestions": "Förslag"
},
"tabs": {
"home": "Hem",
"search": "Sök",
"library": "Bibliotek"
}
}

View File

@@ -1,6 +1,7 @@
import { atom, useAtom } from "jotai";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect } from "react";
import { getLocales } from "expo-localization";
type Settings = {
autoRotate?: boolean;
@@ -10,6 +11,7 @@ type Settings = {
deviceProfile?: "Expo" | "Native" | "Old";
forceDirectPlay?: boolean;
mediaListCollectionIds?: string[];
preferedLanguage?: string;
};
/**
@@ -33,6 +35,7 @@ const loadSettings = async (): Promise<Settings> => {
deviceProfile: "Expo",
forceDirectPlay: false,
mediaListCollectionIds: [],
preferedLanguage: getLocales()[0] || "en",
};
};