mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
Compare commits
2 Commits
feat/tv-os
...
feat/i18n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
752cb1cdc6 | ||
|
|
21c1221138 |
@@ -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
|
||||
|
||||
|
||||
5
app.json
5
app.json
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
36
components/LanguageSwitcher.tsx
Normal file
36
components/LanguageSwitcher.tsx
Normal 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
22
i18n.ts
Normal 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;
|
||||
@@ -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
38
translations/en.json
Normal 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
38
translations/sv.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user