mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-29 22:48:23 +00:00
feat(tv): add channels tab with direct channel playback and live tv controls
This commit is contained in:
183
components/livetv/TVChannelCard.tsx
Normal file
183
components/livetv/TVChannelCard.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import React from "react";
|
||||
import { Animated, Image, Pressable, StyleSheet, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
interface TVChannelCardProps {
|
||||
channel: BaseItemDto;
|
||||
api: Api | null;
|
||||
onPress: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const CARD_WIDTH = 200;
|
||||
const CARD_HEIGHT = 160;
|
||||
|
||||
export const TVChannelCard: React.FC<TVChannelCardProps> = ({
|
||||
channel,
|
||||
api,
|
||||
onPress,
|
||||
hasTVPreferredFocus = false,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({
|
||||
scaleAmount: 1.05,
|
||||
duration: 120,
|
||||
});
|
||||
|
||||
const imageUrl = getPrimaryImageUrl({
|
||||
api,
|
||||
item: channel,
|
||||
quality: 80,
|
||||
width: 200,
|
||||
});
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
style={styles.pressable}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: focused ? "#FFFFFF" : "rgba(255, 255, 255, 0.08)",
|
||||
borderColor: focused ? "#FFFFFF" : "rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
animatedStyle,
|
||||
focused && styles.focusedShadow,
|
||||
]}
|
||||
>
|
||||
{/* Channel logo or number */}
|
||||
<View style={styles.logoContainer}>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={styles.logo}
|
||||
resizeMode='contain'
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.numberFallback,
|
||||
{
|
||||
backgroundColor: focused
|
||||
? "#E5E5E5"
|
||||
: "rgba(255, 255, 255, 0.15)",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.numberText,
|
||||
{
|
||||
fontSize: typography.title,
|
||||
color: focused ? "#000000" : "#FFFFFF",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{channel.ChannelNumber || "?"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Channel name */}
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
styles.channelName,
|
||||
{
|
||||
fontSize: typography.callout,
|
||||
color: focused ? "#000000" : "#FFFFFF",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{channel.Name}
|
||||
</Text>
|
||||
|
||||
{/* Channel number (if name is shown) */}
|
||||
{channel.ChannelNumber && (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.channelNumber,
|
||||
{
|
||||
fontSize: typography.callout,
|
||||
color: focused
|
||||
? "rgba(0, 0, 0, 0.6)"
|
||||
: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
]}
|
||||
>
|
||||
Ch. {channel.ChannelNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pressable: {
|
||||
width: CARD_WIDTH,
|
||||
height: CARD_HEIGHT,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
focusedShadow: {
|
||||
shadowColor: "#FFFFFF",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 12,
|
||||
},
|
||||
logoContainer: {
|
||||
width: 80,
|
||||
height: 60,
|
||||
marginBottom: 8,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
logo: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
numberFallback: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
numberText: {
|
||||
fontWeight: "bold",
|
||||
},
|
||||
channelName: {
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
marginBottom: 4,
|
||||
},
|
||||
channelNumber: {
|
||||
fontWeight: "400",
|
||||
},
|
||||
});
|
||||
|
||||
export { CARD_WIDTH, CARD_HEIGHT };
|
||||
136
components/livetv/TVChannelsGrid.tsx
Normal file
136
components/livetv/TVChannelsGrid.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, ScrollView, StyleSheet, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { TVChannelCard } from "./TVChannelCard";
|
||||
|
||||
const HORIZONTAL_PADDING = 60;
|
||||
const GRID_GAP = 16;
|
||||
|
||||
export const TVChannelsGrid: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const typography = useScaledTVTypography();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
// Fetch all channels
|
||||
const { data: channelsData, isLoading } = useQuery({
|
||||
queryKey: ["livetv", "channels-grid", "all"],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return null;
|
||||
const res = await getLiveTvApi(api).getLiveTvChannels({
|
||||
enableFavoriteSorting: true,
|
||||
userId: user.Id,
|
||||
addCurrentProgram: false,
|
||||
enableUserData: false,
|
||||
enableImageTypes: ["Primary"],
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
const channels = channelsData?.Items ?? [];
|
||||
|
||||
const handleChannelPress = useCallback(
|
||||
(channelId: string | undefined) => {
|
||||
if (channelId) {
|
||||
// Navigate directly to the player to start the channel
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: channelId,
|
||||
audioIndex: "",
|
||||
subtitleIndex: "",
|
||||
mediaSourceId: "",
|
||||
bitrateValue: "",
|
||||
});
|
||||
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||
}
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size='large' color='#FFFFFF' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={[styles.emptyText, { fontSize: typography.body }]}>
|
||||
{t("live_tv.no_channels")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={[
|
||||
styles.contentContainer,
|
||||
{
|
||||
paddingLeft: insets.left + HORIZONTAL_PADDING,
|
||||
paddingRight: insets.right + HORIZONTAL_PADDING,
|
||||
paddingBottom: insets.bottom + 60,
|
||||
},
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.grid}>
|
||||
{channels.map((channel, index) => (
|
||||
<TVChannelCard
|
||||
key={channel.Id ?? index}
|
||||
channel={channel}
|
||||
api={api}
|
||||
onPress={() => handleChannelPress(channel.Id)}
|
||||
// No hasTVPreferredFocus - tab buttons handle initial focus
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingTop: 24,
|
||||
},
|
||||
grid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "flex-start",
|
||||
gap: GRID_GAP,
|
||||
overflow: "visible",
|
||||
paddingVertical: 10, // Extra padding for focus scale animation
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: "rgba(255, 255, 255, 0.6)",
|
||||
},
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||
import { TVChannelsGrid } from "@/components/livetv/TVChannelsGrid";
|
||||
import { TVLiveTVGuide } from "@/components/livetv/TVLiveTVGuide";
|
||||
import { TVLiveTVPlaceholder } from "@/components/livetv/TVLiveTVPlaceholder";
|
||||
import { TVTabButton } from "@/components/tv/TVTabButton";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
@@ -200,6 +202,14 @@ export const TVLiveTVPage: React.FC = () => {
|
||||
return renderProgramsContent();
|
||||
}
|
||||
|
||||
if (activeTab === "guide") {
|
||||
return <TVLiveTVGuide />;
|
||||
}
|
||||
|
||||
if (activeTab === "channels") {
|
||||
return <TVChannelsGrid />;
|
||||
}
|
||||
|
||||
// Placeholder for other tabs
|
||||
const tab = TABS.find((t) => t.id === activeTab);
|
||||
return <TVLiveTVPlaceholder tabName={t(tab?.labelKey || "")} />;
|
||||
@@ -234,13 +244,13 @@ export const TVLiveTVPage: React.FC = () => {
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{TABS.map((tab, index) => (
|
||||
{TABS.map((tab) => (
|
||||
<TVTabButton
|
||||
key={tab.id}
|
||||
label={t(tab.labelKey)}
|
||||
active={activeTab === tab.id}
|
||||
onSelect={() => handleTabSelect(tab.id)}
|
||||
hasTVPreferredFocus={index === 0}
|
||||
hasTVPreferredFocus={activeTab === tab.id}
|
||||
switchOnFocus={true}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -375,6 +375,12 @@ export const Controls: FC<Props> = ({
|
||||
isSeeking,
|
||||
});
|
||||
|
||||
// Live TV detection - check for both Program (when playing from guide) and TvChannel (when playing from channels)
|
||||
const isLiveTV = item?.Type === "Program" || item?.Type === "TvChannel";
|
||||
|
||||
// For live TV, determine if we're at the live edge (within 5 seconds of max)
|
||||
const LIVE_EDGE_THRESHOLD = 5000; // 5 seconds in ms
|
||||
|
||||
const getFinishTime = () => {
|
||||
const now = new Date();
|
||||
const finishTime = new Date(now.getTime() + remainingTime);
|
||||
@@ -540,6 +546,13 @@ export const Controls: FC<Props> = ({
|
||||
);
|
||||
|
||||
const handleSeekForwardButton = useCallback(() => {
|
||||
// For live TV, check if we're already at the live edge
|
||||
if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) {
|
||||
// Already at live edge, don't seek further
|
||||
controlsInteractionRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
const newPosition = Math.min(max.value, progress.value + 30 * 1000);
|
||||
progress.value = newPosition;
|
||||
seek(newPosition);
|
||||
@@ -556,7 +569,14 @@ export const Controls: FC<Props> = ({
|
||||
}, 2000);
|
||||
|
||||
controlsInteractionRef.current();
|
||||
}, [progress, max, seek, calculateTrickplayUrl, updateSeekBubbleTime]);
|
||||
}, [
|
||||
progress,
|
||||
max,
|
||||
seek,
|
||||
calculateTrickplayUrl,
|
||||
updateSeekBubbleTime,
|
||||
isLiveTV,
|
||||
]);
|
||||
|
||||
const handleSeekBackwardButton = useCallback(() => {
|
||||
const newPosition = Math.max(min.value, progress.value - 30 * 1000);
|
||||
@@ -579,6 +599,13 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
// Progress bar D-pad seeking (10s increments for finer control)
|
||||
const handleProgressSeekRight = useCallback(() => {
|
||||
// For live TV, check if we're already at the live edge
|
||||
if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) {
|
||||
// Already at live edge, don't seek further
|
||||
controlsInteractionRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
const newPosition = Math.min(max.value, progress.value + 10 * 1000);
|
||||
progress.value = newPosition;
|
||||
seek(newPosition);
|
||||
@@ -595,7 +622,14 @@ export const Controls: FC<Props> = ({
|
||||
}, 2000);
|
||||
|
||||
controlsInteractionRef.current();
|
||||
}, [progress, max, seek, calculateTrickplayUrl, updateSeekBubbleTime]);
|
||||
}, [
|
||||
progress,
|
||||
max,
|
||||
seek,
|
||||
calculateTrickplayUrl,
|
||||
updateSeekBubbleTime,
|
||||
isLiveTV,
|
||||
]);
|
||||
|
||||
const handleProgressSeekLeft = useCallback(() => {
|
||||
const newPosition = Math.max(min.value, progress.value - 10 * 1000);
|
||||
@@ -618,6 +652,12 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
// Minimal seek mode handlers (only show progress bar, not full controls)
|
||||
const handleMinimalSeekRight = useCallback(() => {
|
||||
// For live TV, check if we're already at the live edge
|
||||
if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) {
|
||||
// Already at live edge, don't seek further
|
||||
return;
|
||||
}
|
||||
|
||||
const newPosition = Math.min(max.value, progress.value + 10 * 1000);
|
||||
progress.value = newPosition;
|
||||
seek(newPosition);
|
||||
@@ -642,6 +682,7 @@ export const Controls: FC<Props> = ({
|
||||
calculateTrickplayUrl,
|
||||
updateSeekBubbleTime,
|
||||
showMinimalSeek,
|
||||
isLiveTV,
|
||||
]);
|
||||
|
||||
const handleMinimalSeekLeft = useCallback(() => {
|
||||
@@ -691,11 +732,23 @@ export const Controls: FC<Props> = ({
|
||||
}, [startMinimalSeekHideTimeout]);
|
||||
|
||||
const startContinuousSeekForward = useCallback(() => {
|
||||
// For live TV, check if we're already at the live edge
|
||||
if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) {
|
||||
// Already at live edge, don't start continuous seeking
|
||||
return;
|
||||
}
|
||||
|
||||
seekAccelerationRef.current = 1;
|
||||
|
||||
handleSeekForwardButton();
|
||||
|
||||
continuousSeekRef.current = setInterval(() => {
|
||||
// For live TV, stop continuous seeking when we hit the live edge
|
||||
if (isLiveTV && max.value - progress.value < LIVE_EDGE_THRESHOLD) {
|
||||
stopContinuousSeeking();
|
||||
return;
|
||||
}
|
||||
|
||||
const seekAmount =
|
||||
CONTROLS_CONSTANTS.LONG_PRESS_INITIAL_SEEK *
|
||||
seekAccelerationRef.current *
|
||||
@@ -718,6 +771,8 @@ export const Controls: FC<Props> = ({
|
||||
seek,
|
||||
calculateTrickplayUrl,
|
||||
updateSeekBubbleTime,
|
||||
isLiveTV,
|
||||
stopContinuousSeeking,
|
||||
]);
|
||||
|
||||
const startContinuousSeekBackward = useCallback(() => {
|
||||
@@ -977,16 +1032,18 @@ export const Controls: FC<Props> = ({
|
||||
<Text style={[styles.timeText, { fontSize: typography.body }]}>
|
||||
{formatTimeString(currentTime, "ms")}
|
||||
</Text>
|
||||
<View style={styles.timeRight}>
|
||||
<Text style={[styles.timeText, { fontSize: typography.body }]}>
|
||||
-{formatTimeString(remainingTime, "ms")}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||
>
|
||||
{t("player.ends_at")} {getFinishTime()}
|
||||
</Text>
|
||||
</View>
|
||||
{!isLiveTV && (
|
||||
<View style={styles.timeRight}>
|
||||
<Text style={[styles.timeText, { fontSize: typography.body }]}>
|
||||
-{formatTimeString(remainingTime, "ms")}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||
>
|
||||
{t("player.ends_at")} {getFinishTime()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
@@ -1012,9 +1069,25 @@ export const Controls: FC<Props> = ({
|
||||
style={[styles.subtitleText, { fontSize: typography.body }]}
|
||||
>{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}</Text>
|
||||
)}
|
||||
<Text style={[styles.titleText, { fontSize: typography.heading }]}>
|
||||
{item?.Name}
|
||||
</Text>
|
||||
<View style={styles.titleRow}>
|
||||
<Text
|
||||
style={[styles.titleText, { fontSize: typography.heading }]}
|
||||
>
|
||||
{item?.Name}
|
||||
</Text>
|
||||
{isLiveTV && (
|
||||
<View style={styles.liveBadge}>
|
||||
<Text
|
||||
style={[
|
||||
styles.liveBadgeText,
|
||||
{ fontSize: typography.callout },
|
||||
]}
|
||||
>
|
||||
{t("player.live")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{item?.Type === "Movie" && (
|
||||
<Text
|
||||
style={[styles.subtitleText, { fontSize: typography.body }]}
|
||||
@@ -1122,16 +1195,18 @@ export const Controls: FC<Props> = ({
|
||||
<Text style={[styles.timeText, { fontSize: typography.body }]}>
|
||||
{formatTimeString(currentTime, "ms")}
|
||||
</Text>
|
||||
<View style={styles.timeRight}>
|
||||
<Text style={[styles.timeText, { fontSize: typography.body }]}>
|
||||
-{formatTimeString(remainingTime, "ms")}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||
>
|
||||
{t("player.ends_at")} {getFinishTime()}
|
||||
</Text>
|
||||
</View>
|
||||
{!isLiveTV && (
|
||||
<View style={styles.timeRight}>
|
||||
<Text style={[styles.timeText, { fontSize: typography.body }]}>
|
||||
-{formatTimeString(remainingTime, "ms")}
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||
>
|
||||
{t("player.ends_at")} {getFinishTime()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
@@ -1160,6 +1235,11 @@ const styles = StyleSheet.create({
|
||||
metadataContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
},
|
||||
subtitleText: {
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
},
|
||||
@@ -1167,6 +1247,16 @@ const styles = StyleSheet.create({
|
||||
color: "#fff",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
liveBadge: {
|
||||
backgroundColor: "#EF4444",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
},
|
||||
liveBadgeText: {
|
||||
color: "#FFF",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
controlButtonsRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -624,6 +624,7 @@
|
||||
"no_links": "No Links"
|
||||
},
|
||||
"player": {
|
||||
"live": "LIVE",
|
||||
"error": "Error",
|
||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
@@ -718,6 +719,9 @@
|
||||
"sports": "Sports",
|
||||
"for_kids": "For Kids",
|
||||
"news": "News",
|
||||
"page_of": "Page {{current}} of {{total}}",
|
||||
"no_programs": "No programs available",
|
||||
"no_channels": "No channels available",
|
||||
"tabs": {
|
||||
"programs": "Programs",
|
||||
"guide": "Guide",
|
||||
|
||||
Reference in New Issue
Block a user