mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-07 04:53:01 +01:00
Compare commits
3 Commits
fix/tv-aud
...
fix/subtit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9677cbece | ||
|
|
97ef9b5ee7 | ||
|
|
d6980cfc8e |
@@ -12,16 +12,11 @@ import {
|
|||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import {
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
useFocusEffect,
|
|
||||||
useLocalSearchParams,
|
|
||||||
useNavigation,
|
|
||||||
} from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
BackHandler,
|
|
||||||
FlatList,
|
FlatList,
|
||||||
Platform,
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -85,9 +80,8 @@ const Page = () => {
|
|||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: string;
|
sortOrder?: string;
|
||||||
filterBy?: string;
|
filterBy?: string;
|
||||||
fromSeeAll?: string;
|
|
||||||
};
|
};
|
||||||
const { libraryId, fromSeeAll } = searchParams;
|
const { libraryId } = searchParams;
|
||||||
|
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const posterSizes = useScaledTVPosterSizes();
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
@@ -118,22 +112,6 @@ const Page = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showOptions } = useTVOptionModal();
|
const { showOptions } = useTVOptionModal();
|
||||||
|
|
||||||
// When this library detail was opened from the home "See All" button, its
|
|
||||||
// libraries stack is just [detail], so the default TV Back would exit to home.
|
|
||||||
// Intercept Back (scoped to while this screen is focused via useFocusEffect) and
|
|
||||||
// route to the library list instead, so the user can switch libraries. Normal
|
|
||||||
// entries from the list keep their native pop-to-list behavior.
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
if (!Platform.isTV || fromSeeAll !== "true") return;
|
|
||||||
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
|
|
||||||
router.replace("/(auth)/(tabs)/(libraries)");
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return () => sub.remove();
|
|
||||||
}, [fromSeeAll, router]),
|
|
||||||
);
|
|
||||||
const { showItemActions } = useTVItemActionModal();
|
const { showItemActions } = useTVItemActionModal();
|
||||||
|
|
||||||
// TV Filter queries
|
// TV Filter queries
|
||||||
@@ -291,23 +269,6 @@ const Page = () => {
|
|||||||
});
|
});
|
||||||
}, [library]);
|
}, [library]);
|
||||||
|
|
||||||
// If this See-All detail was deep-linked on top of the libraries index, collapse
|
|
||||||
// the libraries stack to just this screen. Otherwise the stack is [index, detail],
|
|
||||||
// which the native bottom tab reliably auto-pops back to the index (the detail
|
|
||||||
// "bounces" to the library list ~0.5s after opening). With [detail] alone it stays
|
|
||||||
// put, and Back is handled explicitly by the fromSeeAll interceptor above.
|
|
||||||
const didCollapseRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!Platform.isTV || fromSeeAll !== "true" || didCollapseRef.current)
|
|
||||||
return;
|
|
||||||
const state = navigation.getState();
|
|
||||||
if (state?.routes && state.routes.length > 1) {
|
|
||||||
didCollapseRef.current = true;
|
|
||||||
const top = state.routes[state.routes.length - 1];
|
|
||||||
navigation.reset({ index: 0, routes: [top] } as any);
|
|
||||||
}
|
|
||||||
}, [navigation, fromSeeAll]);
|
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
async ({
|
async ({
|
||||||
pageParam,
|
pageParam,
|
||||||
|
|||||||
@@ -893,27 +893,6 @@ export default function DirectPlayerPage() {
|
|||||||
// Check if we're transcoding
|
// Check if we're transcoding
|
||||||
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
||||||
|
|
||||||
// A transcoded stream only carries the audio track the server encoded
|
|
||||||
// into it — switching requires re-negotiating the stream with the new
|
|
||||||
// index (like the mobile menu's replacePlayer), not an mpv aid change.
|
|
||||||
if (isTranscoding) {
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item?.Id ?? "",
|
|
||||||
audioIndex: String(index),
|
|
||||||
subtitleIndex: String(currentSubtitleIndex),
|
|
||||||
mediaSourceId: stream?.mediaSource?.Id ?? "",
|
|
||||||
bitrateValue: bitrateValue?.toString() ?? "",
|
|
||||||
playbackPosition: msToTicks(progress.get()).toString(),
|
|
||||||
}).toString();
|
|
||||||
// Destroy the current mpv instance BEFORE navigating, same rationale as
|
|
||||||
// goToNextItem/goToPreviousItem: Expo Router briefly holds two players
|
|
||||||
// during the transition, and two simultaneous decoders OOM-kill low-RAM
|
|
||||||
// devices. Resume is preserved via the playbackPosition param.
|
|
||||||
videoRef.current?.destroy().catch(() => {});
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert Jellyfin index to MPV track ID
|
// Convert Jellyfin index to MPV track ID
|
||||||
const mpvTrackId = getMpvAudioId(
|
const mpvTrackId = getMpvAudioId(
|
||||||
stream?.mediaSource,
|
stream?.mediaSource,
|
||||||
@@ -925,14 +904,7 @@ export default function DirectPlayerPage() {
|
|||||||
await videoRef.current?.setAudioTrack?.(mpvTrackId);
|
await videoRef.current?.setAudioTrack?.(mpvTrackId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[stream?.mediaSource],
|
||||||
stream?.mediaSource,
|
|
||||||
item?.Id,
|
|
||||||
currentSubtitleIndex,
|
|
||||||
bitrateValue,
|
|
||||||
router,
|
|
||||||
progress,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// TV subtitle track change handler
|
// TV subtitle track change handler
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
Easing,
|
Easing,
|
||||||
InteractionManager,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
TVFocusGuideView,
|
TVFocusGuideView,
|
||||||
@@ -76,20 +75,6 @@ export default function TVOptionModal() {
|
|||||||
}, [isReady]);
|
}, [isReady]);
|
||||||
|
|
||||||
const handleSelect = (value: any) => {
|
const handleSelect = (value: any) => {
|
||||||
if (modalState?.deferApplyUntilDismissed) {
|
|
||||||
// onSelect navigates (the transcode audio switch replacing the player);
|
|
||||||
// a router.replace fired while this modal is the active route would be
|
|
||||||
// swallowed. Close FIRST, apply after dismissal.
|
|
||||||
const onSelect = modalState.onSelect;
|
|
||||||
store.set(tvOptionModalAtom, null);
|
|
||||||
router.back();
|
|
||||||
InteractionManager.runAfterInteractions(() => onSelect?.(value));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// State-only callers (detail page, library filters, settings): run before
|
|
||||||
// closing so the re-render happens while the modal is up. Deferring it until
|
|
||||||
// after dismissal re-renders the page after focus returns and yanks TV
|
|
||||||
// focus, leaving navigation stuck.
|
|
||||||
modalState?.onSelect(value);
|
modalState?.onSelect(value);
|
||||||
store.set(tvOptionModalAtom, null);
|
store.set(tvOptionModalAtom, null);
|
||||||
router.back();
|
router.back();
|
||||||
|
|||||||
@@ -201,18 +201,12 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
|
|
||||||
const handleSeeAllPress = useCallback(() => {
|
const handleSeeAllPress = useCallback(() => {
|
||||||
if (!parentId) return;
|
if (!parentId) return;
|
||||||
// Navigate into the library detail (lives in the libraries tab) sorted by most
|
|
||||||
// recently added. The `fromSeeAll` flag tells the detail page to (a) collapse
|
|
||||||
// the libraries stack so the native tab can't auto-pop it back to the list, and
|
|
||||||
// (b) intercept Back to route to the library list so the user can switch
|
|
||||||
// libraries. See app/(auth)/(tabs)/(libraries)/[libraryId].tsx.
|
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/[libraryId]",
|
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
||||||
params: {
|
params: {
|
||||||
libraryId: parentId,
|
libraryId: parentId,
|
||||||
sortBy: SortByOption.DateCreated,
|
sortBy: SortByOption.DateCreated,
|
||||||
sortOrder: SortOrderOption.Descending,
|
sortOrder: SortOrderOption.Descending,
|
||||||
fromSeeAll: "true",
|
|
||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
}, [router, parentId]);
|
}, [router, parentId]);
|
||||||
@@ -354,14 +348,11 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
||||||
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
|
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
// No fixed width: the footer must size to the "See All" card so the
|
|
||||||
// FlatList's scrollable content extends to fully reveal it. A fixed
|
|
||||||
// (narrow) width clipped the card at the right edge. Trailing space is
|
|
||||||
// provided by contentContainerStyle.paddingRight.
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
width: sizes.padding.horizontal,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isFetchingNextPage && (
|
{isFetchingNextPage && (
|
||||||
|
|||||||
@@ -564,9 +564,6 @@ export const Controls: FC<Props> = ({
|
|||||||
title: t("item_card.audio"),
|
title: t("item_card.audio"),
|
||||||
options: audioOptions,
|
options: audioOptions,
|
||||||
onSelect: handleAudioChange,
|
onSelect: handleAudioChange,
|
||||||
// In-player audio selection navigates (replacePlayer while transcoding);
|
|
||||||
// apply it after the modal is dismissed so it isn't swallowed.
|
|
||||||
deferApplyUntilDismissed: true,
|
|
||||||
});
|
});
|
||||||
controlsInteractionRef.current();
|
controlsInteractionRef.current();
|
||||||
}, [showOptions, t, audioOptions, handleAudioChange]);
|
}, [showOptions, t, audioOptions, handleAudioChange]);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ interface ShowOptionsParams<T> {
|
|||||||
onSelect: (value: T) => void;
|
onSelect: (value: T) => void;
|
||||||
cardWidth?: number;
|
cardWidth?: number;
|
||||||
cardHeight?: number;
|
cardHeight?: number;
|
||||||
deferApplyUntilDismissed?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTVOptionModal = () => {
|
export const useTVOptionModal = () => {
|
||||||
@@ -27,7 +26,6 @@ export const useTVOptionModal = () => {
|
|||||||
onSelect: params.onSelect,
|
onSelect: params.onSelect,
|
||||||
cardWidth: params.cardWidth,
|
cardWidth: params.cardWidth,
|
||||||
cardHeight: params.cardHeight,
|
cardHeight: params.cardHeight,
|
||||||
deferApplyUntilDismissed: params.deferApplyUntilDismissed,
|
|
||||||
});
|
});
|
||||||
router.push("/(auth)/tv-option-modal");
|
router.push("/(auth)/tv-option-modal");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,15 +13,6 @@ export type TVOptionModalState = {
|
|||||||
onSelect: (value: any) => void;
|
onSelect: (value: any) => void;
|
||||||
cardWidth?: number;
|
cardWidth?: number;
|
||||||
cardHeight?: number;
|
cardHeight?: number;
|
||||||
/**
|
|
||||||
* Run onSelect AFTER the modal route is dismissed. Needed only when onSelect
|
|
||||||
* navigates (the in-player audio switch replacing the player while
|
|
||||||
* transcoding), which the still-active modal route would otherwise swallow.
|
|
||||||
* Default (false) runs onSelect before closing, so state-only callers (detail
|
|
||||||
* page, library filters, settings) don't re-render after focus returns and
|
|
||||||
* lose TV focus.
|
|
||||||
*/
|
|
||||||
deferApplyUntilDismissed?: boolean;
|
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
export const tvOptionModalAtom = atom<TVOptionModalState>(null);
|
export const tvOptionModalAtom = atom<TVOptionModalState>(null);
|
||||||
|
|||||||
@@ -44,9 +44,22 @@ export const isSubtitleInMpv = (
|
|||||||
/**
|
/**
|
||||||
* Calculate the MPV track ID for a given Jellyfin subtitle index.
|
* Calculate the MPV track ID for a given Jellyfin subtitle index.
|
||||||
*
|
*
|
||||||
* MPV track IDs are 1-based and only count subtitles that are actually in MPV.
|
* MPV track IDs are 1-based, but MPV's track list is NOT in MediaStreams order:
|
||||||
* We iterate through all subtitles, counting only those in MPV, until we find
|
* 1. Embedded/HLS subs are enumerated from the container (or HLS playlist)
|
||||||
* the one matching the Jellyfin index.
|
* first, in MediaStreams order.
|
||||||
|
* 2. External subs are appended via `sub-add` AFTER the file loads, in the
|
||||||
|
* order they are passed to MPV (here, also MediaStreams order — see
|
||||||
|
* direct-player.tsx where the externalSubtitles array is built by
|
||||||
|
* filtering MediaStreams).
|
||||||
|
*
|
||||||
|
* Iterating in pure MediaStreams order produces the wrong MPV ID whenever an
|
||||||
|
* External sub is listed before an Embed sub in MediaStreams (common when
|
||||||
|
* Jellyfin prepends a converted SRT/VTT ahead of an original PGS/ASS track),
|
||||||
|
* causing e.g. English to select Spanish. We therefore count in two phases
|
||||||
|
* that mirror MPV's actual ordering.
|
||||||
|
*
|
||||||
|
* Image-based subs (PGS/VOBSUB) during transcoding are burned into the video
|
||||||
|
* and absent from MPV's track list; they are skipped in both phases.
|
||||||
*
|
*
|
||||||
* @param mediaSource - The media source containing subtitle streams
|
* @param mediaSource - The media source containing subtitle streams
|
||||||
* @param jellyfinSubtitleIndex - The Jellyfin server-side subtitle index (-1 = disabled)
|
* @param jellyfinSubtitleIndex - The Jellyfin server-side subtitle index (-1 = disabled)
|
||||||
@@ -74,15 +87,31 @@ export const getMpvSubtitleId = (
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count MPV track position (1-based)
|
const isExternal = (sub: MediaStream) =>
|
||||||
|
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
||||||
|
|
||||||
let mpvIndex = 0;
|
let mpvIndex = 0;
|
||||||
|
|
||||||
|
// Phase 1: embedded / HLS subs — these occupy MPV track IDs first because
|
||||||
|
// they come from the container or HLS playlist.
|
||||||
for (const sub of allSubs) {
|
for (const sub of allSubs) {
|
||||||
if (isSubtitleInMpv(sub, isTranscoding)) {
|
if (isExternal(sub)) continue;
|
||||||
|
if (!isSubtitleInMpv(sub, isTranscoding)) continue;
|
||||||
mpvIndex++;
|
mpvIndex++;
|
||||||
if (sub.Index === jellyfinSubtitleIndex) {
|
if (sub.Index === jellyfinSubtitleIndex) {
|
||||||
return mpvIndex;
|
return mpvIndex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 2: external subs — appended via `sub-add` after the file loads,
|
||||||
|
// so they come last in MPV's track list.
|
||||||
|
for (const sub of allSubs) {
|
||||||
|
if (!isExternal(sub)) continue;
|
||||||
|
if (!isSubtitleInMpv(sub, isTranscoding)) continue;
|
||||||
|
mpvIndex++;
|
||||||
|
if (sub.Index === jellyfinSubtitleIndex) {
|
||||||
|
return mpvIndex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user