mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-12 15:32:54 +01:00
feat(player): wire chapter UI into native player controls
Threads chapters + duration through Controls -> BottomControls so the ChapterTicks overlay, ChapterList modal and current-chapter label have the data they need. BottomControls - Memoizes chapterMarkerList (markers within the media duration) once per (chapters, durationMs) change and feeds it to ChapterTicks. - hasChapters gates the bookmark icon + list modal; nothing renders when chapters are missing or below two real markers. - Current chapter name shown as a small label below the title/year during playback; the same helper feeds the trickplay bubble while scrubbing. Both labels disappear gracefully when chapters are absent. useVideoSlider - Adds seekTo(value): a programmatic seek for non-gesture entry points (chapter list, hot-keys). Reads isPlaying directly instead of wasPlayingRef — which is only populated inside handleSliderStart, so a tap-to-seek on the chapter list previously either stranded paused playback or auto-resumed against a manual pause. TrickplayBubble - Adds an optional chapterName prop; renders a small left-aligned overlay inside the preview frame (Jellyfin web style) showing chapter name above the timestamp. Hides the chapter line entirely when null. - zIndex + elevation so the bubble lands in front of the title / surrounding overlays. - Slight reposition (bottom -20, paddingTop 12) brings the bubble closer to the slider. translations/en.json - chapters.title / chapters.chapter_number / chapters.open / chapters.close keys for the list modal and the bookmark a11y label.
This commit is contained in:
@@ -1,18 +1,34 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { FC } from "react";
|
import type {
|
||||||
import { View } from "react-native";
|
BaseItemDto,
|
||||||
|
ChapterInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { type FC, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import { type SharedValue } from "react-native-reanimated";
|
import { type SharedValue } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||||
|
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||||
import SkipButton from "./SkipButton";
|
import SkipButton from "./SkipButton";
|
||||||
import { TimeDisplay } from "./TimeDisplay";
|
import { TimeDisplay } from "./TimeDisplay";
|
||||||
import { TrickplayBubble } from "./TrickplayBubble";
|
import { TrickplayBubble } from "./TrickplayBubble";
|
||||||
|
|
||||||
|
// Chapter tick height in dp — matches the slider track height for a clean,
|
||||||
|
// flush look (no top/bottom overflow).
|
||||||
|
const TICK_HEIGHT = 10;
|
||||||
|
|
||||||
interface BottomControlsProps {
|
interface BottomControlsProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
|
/** Item chapters, used for the tick overlay and chapter list. */
|
||||||
|
chapters?: ChapterInfo[] | null;
|
||||||
|
/** Total media duration in milliseconds. */
|
||||||
|
durationMs: number;
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
isSliding: boolean;
|
isSliding: boolean;
|
||||||
showRemoteBubble: boolean;
|
showRemoteBubble: boolean;
|
||||||
@@ -38,6 +54,8 @@ interface BottomControlsProps {
|
|||||||
handleSliderChange: (value: number) => void;
|
handleSliderChange: (value: number) => void;
|
||||||
handleTouchStart: () => void;
|
handleTouchStart: () => void;
|
||||||
handleTouchEnd: () => void;
|
handleTouchEnd: () => void;
|
||||||
|
/** Programmatic seek (chapter list, hotkeys) — bypasses slide gesture state. */
|
||||||
|
seekTo: (value: number) => void;
|
||||||
|
|
||||||
// Trickplay props
|
// Trickplay props
|
||||||
trickPlayUrl: {
|
trickPlayUrl: {
|
||||||
@@ -61,6 +79,8 @@ interface BottomControlsProps {
|
|||||||
|
|
||||||
export const BottomControls: FC<BottomControlsProps> = ({
|
export const BottomControls: FC<BottomControlsProps> = ({
|
||||||
item,
|
item,
|
||||||
|
chapters,
|
||||||
|
durationMs,
|
||||||
showControls,
|
showControls,
|
||||||
isSliding,
|
isSliding,
|
||||||
showRemoteBubble,
|
showRemoteBubble,
|
||||||
@@ -84,12 +104,38 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
handleSliderChange,
|
handleSliderChange,
|
||||||
handleTouchStart,
|
handleTouchStart,
|
||||||
handleTouchEnd,
|
handleTouchEnd,
|
||||||
|
seekTo,
|
||||||
trickPlayUrl,
|
trickPlayUrl,
|
||||||
trickplayInfo,
|
trickplayInfo,
|
||||||
time,
|
time,
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const [chapterListVisible, setChapterListVisible] = useState(false);
|
||||||
|
|
||||||
|
// Only expose chapter UI when there are at least two real markers.
|
||||||
|
const chapterMarkerList = useMemo(
|
||||||
|
() => chapterMarkers(chapters, durationMs),
|
||||||
|
[chapters, durationMs],
|
||||||
|
);
|
||||||
|
const hasChapters = chapterMarkerList.length > 1;
|
||||||
|
|
||||||
|
// Current chapter name for the always-visible header label (live playback).
|
||||||
|
const currentChapterName = useMemo(
|
||||||
|
() => (hasChapters ? chapterNameAt(currentTime, chapters) : null),
|
||||||
|
[hasChapters, currentTime, chapters],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Chapter name at the scrubbed position for the trickplay bubble. `time` is
|
||||||
|
// an {h,m,s} object derived from the slider's dragged value — convert back
|
||||||
|
// to ms for the lookup. Only useful while actively scrubbing.
|
||||||
|
const scrubChapterName = useMemo(() => {
|
||||||
|
if (!hasChapters) return null;
|
||||||
|
const scrubMs =
|
||||||
|
(time.hours * 3600 + time.minutes * 60 + time.seconds) * 1000;
|
||||||
|
return chapterNameAt(scrubMs, chapters);
|
||||||
|
}, [hasChapters, time.hours, time.minutes, time.seconds, chapters]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -131,8 +177,25 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
{item?.Type === "Audio" && (
|
{item?.Type === "Audio" && (
|
||||||
<Text className='text-xs opacity-50'>{item?.Album}</Text>
|
<Text className='text-xs opacity-50'>{item?.Album}</Text>
|
||||||
)}
|
)}
|
||||||
|
{currentChapterName ? (
|
||||||
|
<Text className='text-xs opacity-70 mt-1' numberOfLines={1}>
|
||||||
|
{currentChapterName}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row space-x-2 shrink-0'>
|
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||||
|
{hasChapters && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setChapterListVisible(true)}
|
||||||
|
hitSlop={10}
|
||||||
|
className='justify-center mr-4'
|
||||||
|
style={{ marginTop: 10 }}
|
||||||
|
accessibilityRole='button'
|
||||||
|
accessibilityLabel={t("chapters.open")}
|
||||||
|
>
|
||||||
|
<Ionicons name='bookmarks' size={24} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
@@ -176,6 +239,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
height: 10,
|
height: 10,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "stretch",
|
alignItems: "stretch",
|
||||||
|
// Allow chapter ticks taller than the 10px track to bleed out
|
||||||
|
// top/bottom (RN defaults to overflow: "hidden" on Android).
|
||||||
|
overflow: "visible",
|
||||||
}}
|
}}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
@@ -203,6 +269,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
trickPlayUrl={trickPlayUrl}
|
trickPlayUrl={trickPlayUrl}
|
||||||
trickplayInfo={trickplayInfo}
|
trickplayInfo={trickplayInfo}
|
||||||
time={time}
|
time={time}
|
||||||
|
chapterName={scrubChapterName}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -212,6 +279,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
minimumValue={min}
|
minimumValue={min}
|
||||||
maximumValue={max}
|
maximumValue={max}
|
||||||
/>
|
/>
|
||||||
|
<ChapterTicks markers={chapterMarkerList} height={TICK_HEIGHT} />
|
||||||
</View>
|
</View>
|
||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
@@ -219,6 +287,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<ChapterList
|
||||||
|
visible={chapterListVisible}
|
||||||
|
chapters={chapters}
|
||||||
|
currentPositionMs={currentTime}
|
||||||
|
onSeek={seekTo}
|
||||||
|
onClose={() => setChapterListVisible(false)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ export const Controls: FC<Props> = ({
|
|||||||
handleTouchEnd,
|
handleTouchEnd,
|
||||||
handleSliderComplete,
|
handleSliderComplete,
|
||||||
handleSliderChange,
|
handleSliderChange,
|
||||||
|
seekTo,
|
||||||
} = useVideoSlider({
|
} = useVideoSlider({
|
||||||
progress,
|
progress,
|
||||||
isSeeking,
|
isSeeking,
|
||||||
@@ -528,6 +529,8 @@ export const Controls: FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<BottomControls
|
<BottomControls
|
||||||
item={item}
|
item={item}
|
||||||
|
chapters={item.Chapters}
|
||||||
|
durationMs={maxMs}
|
||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
isSliding={isSliding}
|
isSliding={isSliding}
|
||||||
showRemoteBubble={showRemoteBubble}
|
showRemoteBubble={showRemoteBubble}
|
||||||
@@ -551,6 +554,7 @@ export const Controls: FC<Props> = ({
|
|||||||
handleSliderChange={handleSliderChange}
|
handleSliderChange={handleSliderChange}
|
||||||
handleTouchStart={handleTouchStart}
|
handleTouchStart={handleTouchStart}
|
||||||
handleTouchEnd={handleTouchEnd}
|
handleTouchEnd={handleTouchEnd}
|
||||||
|
seekTo={seekTo}
|
||||||
trickPlayUrl={trickPlayUrl}
|
trickPlayUrl={trickPlayUrl}
|
||||||
trickplayInfo={trickplayInfo}
|
trickplayInfo={trickplayInfo}
|
||||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||||
|
|||||||
@@ -22,12 +22,15 @@ interface TrickplayBubbleProps {
|
|||||||
minutes: number;
|
minutes: number;
|
||||||
seconds: number;
|
seconds: number;
|
||||||
};
|
};
|
||||||
|
/** Chapter name at the scrubbed position, if any. */
|
||||||
|
chapterName?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||||
trickPlayUrl,
|
trickPlayUrl,
|
||||||
trickplayInfo,
|
trickplayInfo,
|
||||||
time,
|
time,
|
||||||
|
chapterName,
|
||||||
}) => {
|
}) => {
|
||||||
if (!trickPlayUrl || !trickplayInfo) {
|
if (!trickPlayUrl || !trickplayInfo) {
|
||||||
return null;
|
return null;
|
||||||
@@ -36,18 +39,29 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
|||||||
const { x, y, url } = trickPlayUrl;
|
const { x, y, url } = trickPlayUrl;
|
||||||
const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH;
|
const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH;
|
||||||
const tileHeight = tileWidth / trickplayInfo.aspectRatio!;
|
const tileHeight = tileWidth / trickplayInfo.aspectRatio!;
|
||||||
|
const timeStr = `${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||||
|
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||||
|
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`;
|
||||||
|
|
||||||
|
// Slightly larger preview than before (scale 1.6 vs old 1.4) to give the
|
||||||
|
// overlay text more room and feel closer to the Jellyfin web style.
|
||||||
|
const previewScale = 1.6;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: -62,
|
left: -62,
|
||||||
bottom: 0,
|
// Drop the bubble closer to the slider — less floating-high feel.
|
||||||
paddingTop: 30,
|
bottom: -20,
|
||||||
|
paddingTop: 12,
|
||||||
paddingBottom: 5,
|
paddingBottom: 5,
|
||||||
width: tileWidth * 1.5,
|
width: tileWidth * 1.5,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
// Bring the bubble in front of the player title / overlays.
|
||||||
|
zIndex: 999,
|
||||||
|
elevation: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@@ -55,7 +69,7 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
|||||||
width: tileWidth,
|
width: tileWidth,
|
||||||
height: tileHeight,
|
height: tileHeight,
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
transform: [{ scale: 1.4 }],
|
transform: [{ scale: previewScale }],
|
||||||
borderRadius: 5,
|
borderRadius: 5,
|
||||||
}}
|
}}
|
||||||
className='bg-neutral-800 overflow-hidden'
|
className='bg-neutral-800 overflow-hidden'
|
||||||
@@ -75,17 +89,51 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
|||||||
source={{ uri: url }}
|
source={{ uri: url }}
|
||||||
contentFit='cover'
|
contentFit='cover'
|
||||||
/>
|
/>
|
||||||
|
{/*
|
||||||
|
* Bottom-right overlay (Jellyfin web style) — chapter name (small,
|
||||||
|
* faded) above the timestamp (small, bold). Sits on top of the
|
||||||
|
* trickplay frame inside the same overflow:hidden container so it
|
||||||
|
* always stays within the bubble bounds.
|
||||||
|
*/}
|
||||||
|
<View
|
||||||
|
pointerEvents='none'
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 4,
|
||||||
|
bottom: 3,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
paddingHorizontal: 3,
|
||||||
|
paddingVertical: 1,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.55)",
|
||||||
|
maxWidth: tileWidth - 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chapterName ? (
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 7,
|
||||||
|
opacity: 0.85,
|
||||||
|
lineHeight: 9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chapterName}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: "600",
|
||||||
|
lineHeight: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{timeStr}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
marginTop: 30,
|
|
||||||
fontSize: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
|
||||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
|
||||||
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,6 +74,21 @@ export function useVideoSlider({
|
|||||||
[seek, play, progress, isSeeking],
|
[seek, play, progress, isSeeking],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Programmatic seek (chapter list, hotkeys) that bypasses the slide gesture.
|
||||||
|
// Reads `isPlaying` directly instead of `wasPlayingRef`, which is only set
|
||||||
|
// during a real slide and would carry stale state on a tap-to-seek.
|
||||||
|
const seekTo = useCallback(
|
||||||
|
(value: number) => {
|
||||||
|
const seekValue = Math.max(0, Math.floor(value));
|
||||||
|
progress.value = seekValue;
|
||||||
|
seek(seekValue);
|
||||||
|
if (isPlaying) {
|
||||||
|
play();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[seek, play, progress, isPlaying],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSliderChange = useCallback(
|
const handleSliderChange = useCallback(
|
||||||
debounce((value: number) => {
|
debounce((value: number) => {
|
||||||
// Convert ms to ticks for trickplay
|
// Convert ms to ticks for trickplay
|
||||||
@@ -96,5 +111,6 @@ export function useVideoSlider({
|
|||||||
handleTouchEnd,
|
handleTouchEnd,
|
||||||
handleSliderComplete,
|
handleSliderComplete,
|
||||||
handleSliderChange,
|
handleSliderChange,
|
||||||
|
seekTo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -610,6 +610,12 @@
|
|||||||
"downloaded_file_no": "No",
|
"downloaded_file_no": "No",
|
||||||
"downloaded_file_cancel": "Cancel"
|
"downloaded_file_cancel": "Cancel"
|
||||||
},
|
},
|
||||||
|
"chapters": {
|
||||||
|
"title": "Chapters",
|
||||||
|
"chapter_number": "Chapter {{number}}",
|
||||||
|
"open": "Open chapters",
|
||||||
|
"close": "Close chapters"
|
||||||
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Next Up",
|
"next_up": "Next Up",
|
||||||
"no_items_to_display": "No Items to Display",
|
"no_items_to_display": "No Items to Display",
|
||||||
|
|||||||
Reference in New Issue
Block a user