mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-28 05:58:30 +00:00
feat(chromecast): integrate autoskip segments into chromecast player
- Merge autoskip branch with segment detection - Update useChromecastSegments to use real segment API - Support intro, credits, recap, commercial, and preview segments - Add auto-skip support based on user settings - Ready for full testing
This commit is contained in:
@@ -11,6 +11,7 @@ import { withLayoutContext } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { ChromecastMiniPlayer } from "@/components/chromecast/ChromecastMiniPlayer";
|
||||
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
||||
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
@@ -118,6 +119,7 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
</NativeTabs>
|
||||
<ChromecastMiniPlayer />
|
||||
<MiniPlayerBar />
|
||||
<MusicPlaybackEngine />
|
||||
</View>
|
||||
|
||||
194
components/chromecast/ChromecastDeviceSheet.tsx
Normal file
194
components/chromecast/ChromecastDeviceSheet.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Chromecast Device Info Sheet
|
||||
* Shows device details, volume control, and disconnect option
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useState } from "react";
|
||||
import { Modal, Pressable, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import type { Device } from "react-native-google-cast";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface ChromecastDeviceSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
device: Device | null;
|
||||
onDisconnect: () => Promise<void>;
|
||||
volume?: number;
|
||||
onVolumeChange?: (volume: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
device,
|
||||
onDisconnect,
|
||||
volume = 0.5,
|
||||
onVolumeChange,
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||
const volumeValue = useSharedValue(volume * 100);
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
setIsDisconnecting(true);
|
||||
try {
|
||||
await onDisconnect();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to disconnect:", error);
|
||||
} finally {
|
||||
setIsDisconnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeComplete = async (value: number) => {
|
||||
if (onVolumeChange) {
|
||||
await onVolumeChange(value / 100);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType='slide'
|
||||
presentationStyle='pageSheet'
|
||||
onRequestClose={onClose}
|
||||
transparent
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||
>
|
||||
<Ionicons name='tv' size={24} color='#e50914' />
|
||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
||||
Chromecast
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Device info */}
|
||||
<View style={{ padding: 16 }}>
|
||||
<View style={{ marginBottom: 20 }}>
|
||||
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
|
||||
Device Name
|
||||
</Text>
|
||||
<Text style={{ color: "white", fontSize: 16, fontWeight: "500" }}>
|
||||
{device?.friendlyName || device?.deviceId || "Unknown Device"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{device?.deviceId && (
|
||||
<View style={{ marginBottom: 20 }}>
|
||||
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
|
||||
Device ID
|
||||
</Text>
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 14 }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{device.deviceId}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Volume control */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#999", fontSize: 12 }}>Volume</Text>
|
||||
<Text style={{ color: "white", fontSize: 14 }}>
|
||||
{Math.round((volume || 0) * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||
>
|
||||
<Ionicons name='volume-low' size={20} color='#999' />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Slider
|
||||
style={{ width: "100%", height: 40 }}
|
||||
progress={volumeValue}
|
||||
minimumValue={useSharedValue(0)}
|
||||
maximumValue={useSharedValue(100)}
|
||||
theme={{
|
||||
disableMinTrackTintColor: "#333",
|
||||
maximumTrackTintColor: "#333",
|
||||
minimumTrackTintColor: "#e50914",
|
||||
bubbleBackgroundColor: "#e50914",
|
||||
}}
|
||||
onValueChange={(value) => {
|
||||
volumeValue.value = value;
|
||||
}}
|
||||
onSlidingComplete={handleVolumeComplete}
|
||||
/>
|
||||
</View>
|
||||
<Ionicons name='volume-high' size={20} color='#999' />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Disconnect button */}
|
||||
<Pressable
|
||||
onPress={handleDisconnect}
|
||||
disabled={isDisconnecting}
|
||||
style={{
|
||||
backgroundColor: "#e50914",
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
opacity: isDisconnecting ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='power' size={20} color='white' />
|
||||
<Text style={{ color: "white", fontSize: 16, fontWeight: "600" }}>
|
||||
{isDisconnecting ? "Disconnecting..." : "Stop Casting"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
178
components/chromecast/ChromecastEpisodeList.tsx
Normal file
178
components/chromecast/ChromecastEpisodeList.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Episode List for Chromecast Player
|
||||
* Displays list of episodes for TV shows with thumbnails
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import React from "react";
|
||||
import { FlatList, Modal, Pressable, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { truncateTitle } from "@/utils/chromecast/helpers";
|
||||
|
||||
interface ChromecastEpisodeListProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
currentItem: BaseItemDto | null;
|
||||
episodes: BaseItemDto[];
|
||||
onSelectEpisode: (episode: BaseItemDto) => void;
|
||||
}
|
||||
|
||||
export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
currentItem,
|
||||
episodes,
|
||||
onSelectEpisode,
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const renderEpisode = ({ item }: { item: BaseItemDto }) => {
|
||||
const isCurrentEpisode = item.Id === currentItem?.Id;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
onSelectEpisode(item);
|
||||
onClose();
|
||||
}}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
padding: 12,
|
||||
backgroundColor: isCurrentEpisode ? "#e50914" : "transparent",
|
||||
borderRadius: 8,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<View
|
||||
style={{
|
||||
width: 120,
|
||||
height: 68,
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
{item.ImageTags?.Primary && (
|
||||
<Image
|
||||
source={{
|
||||
uri: `${item.Id}/Images/Primary`,
|
||||
}}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
)}
|
||||
{!item.ImageTags?.Primary && (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='film-outline' size={32} color='#333' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Episode info */}
|
||||
<View style={{ flex: 1, marginLeft: 12, justifyContent: "center" }}>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.IndexNumber}. {truncateTitle(item.Name || "Unknown", 30)}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: "#999",
|
||||
fontSize: 12,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.Overview || "No description available"}
|
||||
</Text>
|
||||
{item.RunTimeTicks && (
|
||||
<Text
|
||||
style={{
|
||||
color: "#666",
|
||||
fontSize: 11,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{Math.round((item.RunTimeTicks / 600000000) * 10) / 10} min
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{isCurrentEpisode && (
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='play-circle' size={24} color='white' />
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType='slide'
|
||||
presentationStyle='pageSheet'
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "#000",
|
||||
paddingTop: insets.top,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
||||
Episodes
|
||||
</Text>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Episode list */}
|
||||
<FlatList
|
||||
data={episodes}
|
||||
renderItem={renderEpisode}
|
||||
keyExtractor={(item) => item.Id || ""}
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
367
components/chromecast/ChromecastSettingsMenu.tsx
Normal file
367
components/chromecast/ChromecastSettingsMenu.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Chromecast Settings Menu
|
||||
* Allows users to configure audio, subtitles, quality, and playback speed
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React, { useState } from "react";
|
||||
import { Modal, Pressable, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import type {
|
||||
AudioTrack,
|
||||
MediaSource,
|
||||
SubtitleTrack,
|
||||
} from "@/utils/chromecast/options";
|
||||
|
||||
interface ChromecastSettingsMenuProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
item: BaseItemDto;
|
||||
mediaSources: MediaSource[];
|
||||
selectedMediaSource: MediaSource | null;
|
||||
onMediaSourceChange: (source: MediaSource) => void;
|
||||
audioTracks: AudioTrack[];
|
||||
selectedAudioTrack: AudioTrack | null;
|
||||
onAudioTrackChange: (track: AudioTrack) => void;
|
||||
subtitleTracks: SubtitleTrack[];
|
||||
selectedSubtitleTrack: SubtitleTrack | null;
|
||||
onSubtitleTrackChange: (track: SubtitleTrack | null) => void;
|
||||
playbackSpeed: number;
|
||||
onPlaybackSpeedChange: (speed: number) => void;
|
||||
showTechnicalInfo: boolean;
|
||||
onToggleTechnicalInfo: () => void;
|
||||
}
|
||||
|
||||
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
||||
|
||||
export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
item,
|
||||
mediaSources,
|
||||
selectedMediaSource,
|
||||
onMediaSourceChange,
|
||||
audioTracks,
|
||||
selectedAudioTrack,
|
||||
onAudioTrackChange,
|
||||
subtitleTracks,
|
||||
selectedSubtitleTrack,
|
||||
onSubtitleTrackChange,
|
||||
playbackSpeed,
|
||||
onPlaybackSpeedChange,
|
||||
showTechnicalInfo,
|
||||
onToggleTechnicalInfo,
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [expandedSection, setExpandedSection] = useState<string | null>(null);
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSection(expandedSection === section ? null : section);
|
||||
};
|
||||
|
||||
const renderSectionHeader = (
|
||||
title: string,
|
||||
icon: keyof typeof Ionicons.glyphMap,
|
||||
sectionKey: string,
|
||||
) => (
|
||||
<Pressable
|
||||
onPress={() => toggleSection(sectionKey)}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
||||
<Ionicons name={icon} size={20} color='white' />
|
||||
<Text style={{ color: "white", fontSize: 16, fontWeight: "500" }}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons
|
||||
name={expandedSection === sectionKey ? "chevron-up" : "chevron-down"}
|
||||
size={20}
|
||||
color='#999'
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType='slide'
|
||||
presentationStyle='pageSheet'
|
||||
onRequestClose={onClose}
|
||||
transparent
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
maxHeight: "80%",
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
||||
Playback Settings
|
||||
</Text>
|
||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView>
|
||||
{/* Quality/Media Source */}
|
||||
{renderSectionHeader("Quality", "film-outline", "quality")}
|
||||
{expandedSection === "quality" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{mediaSources.map((source) => (
|
||||
<Pressable
|
||||
key={source.id}
|
||||
onPress={() => {
|
||||
onMediaSourceChange(source);
|
||||
setExpandedSection(null);
|
||||
}}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
backgroundColor:
|
||||
selectedMediaSource?.id === source.id
|
||||
? "#2a2a2a"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text style={{ color: "white", fontSize: 15 }}>
|
||||
{source.name}
|
||||
</Text>
|
||||
{source.bitrate && (
|
||||
<Text
|
||||
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
|
||||
>
|
||||
{Math.round(source.bitrate / 1000000)} Mbps
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{selectedMediaSource?.id === source.id && (
|
||||
<Ionicons name='checkmark' size={20} color='#e50914' />
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Audio Tracks */}
|
||||
{renderSectionHeader("Audio", "musical-notes", "audio")}
|
||||
{expandedSection === "audio" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{audioTracks.map((track) => (
|
||||
<Pressable
|
||||
key={track.index}
|
||||
onPress={() => {
|
||||
onAudioTrackChange(track);
|
||||
setExpandedSection(null);
|
||||
}}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
backgroundColor:
|
||||
selectedAudioTrack?.index === track.index
|
||||
? "#2a2a2a"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text style={{ color: "white", fontSize: 15 }}>
|
||||
{track.displayTitle || track.language || "Unknown"}
|
||||
</Text>
|
||||
{track.codec && (
|
||||
<Text
|
||||
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
|
||||
>
|
||||
{track.codec.toUpperCase()}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{selectedAudioTrack?.index === track.index && (
|
||||
<Ionicons name='checkmark' size={20} color='#e50914' />
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Subtitle Tracks */}
|
||||
{renderSectionHeader("Subtitles", "text", "subtitles")}
|
||||
{expandedSection === "subtitles" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
onSubtitleTrackChange(null);
|
||||
setExpandedSection(null);
|
||||
}}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
backgroundColor:
|
||||
selectedSubtitleTrack === null
|
||||
? "#2a2a2a"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "white", fontSize: 15 }}>None</Text>
|
||||
{selectedSubtitleTrack === null && (
|
||||
<Ionicons name='checkmark' size={20} color='#e50914' />
|
||||
)}
|
||||
</Pressable>
|
||||
{subtitleTracks.map((track) => (
|
||||
<Pressable
|
||||
key={track.index}
|
||||
onPress={() => {
|
||||
onSubtitleTrackChange(track);
|
||||
setExpandedSection(null);
|
||||
}}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
backgroundColor:
|
||||
selectedSubtitleTrack?.index === track.index
|
||||
? "#2a2a2a"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text style={{ color: "white", fontSize: 15 }}>
|
||||
{track.displayTitle || track.language || "Unknown"}
|
||||
</Text>
|
||||
{track.codec && (
|
||||
<Text
|
||||
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
|
||||
>
|
||||
{track.codec.toUpperCase()}
|
||||
{track.isForced && " • Forced"}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{selectedSubtitleTrack?.index === track.index && (
|
||||
<Ionicons name='checkmark' size={20} color='#e50914' />
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Playback Speed */}
|
||||
{renderSectionHeader("Playback Speed", "speedometer", "speed")}
|
||||
{expandedSection === "speed" && (
|
||||
<View style={{ paddingVertical: 8 }}>
|
||||
{PLAYBACK_SPEEDS.map((speed) => (
|
||||
<Pressable
|
||||
key={speed}
|
||||
onPress={() => {
|
||||
onPlaybackSpeedChange(speed);
|
||||
setExpandedSection(null);
|
||||
}}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
backgroundColor:
|
||||
playbackSpeed === speed ? "#2a2a2a" : "transparent",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "white", fontSize: 15 }}>
|
||||
{speed === 1 ? "Normal" : `${speed}x`}
|
||||
</Text>
|
||||
{playbackSpeed === speed && (
|
||||
<Ionicons name='checkmark' size={20} color='#e50914' />
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Technical Info Toggle */}
|
||||
<Pressable
|
||||
onPress={onToggleTechnicalInfo}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#333",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
||||
>
|
||||
<Ionicons name='information-circle' size={20} color='white' />
|
||||
<Text
|
||||
style={{ color: "white", fontSize: 16, fontWeight: "500" }}
|
||||
>
|
||||
Show Technical Info
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
width: 50,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
backgroundColor: showTechnicalInfo ? "#e50914" : "#333",
|
||||
justifyContent: "center",
|
||||
alignItems: showTechnicalInfo ? "flex-end" : "flex-start",
|
||||
padding: 2,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user