mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-22 23:06:39 +01:00
Compare commits
8 Commits
renovate/d
...
feat/chapt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31daeda558 | ||
|
|
1baf4fab3b | ||
|
|
f061e3b757 | ||
|
|
87b181b66f | ||
|
|
e649414e3f | ||
|
|
ff3a656d8a | ||
|
|
4d44e4d7b0 | ||
|
|
df4904d2fd |
2
.github/workflows/build-apps.yml
vendored
2
.github/workflows/build-apps.yml
vendored
@@ -344,7 +344,7 @@ jobs:
|
|||||||
xcode-version: "26.2"
|
xcode-version: "26.2"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
uses: expo/expo-github-action@main
|
||||||
with:
|
with:
|
||||||
eas-version: latest
|
eas-version: latest
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|||||||
127
components/chapters/ChapterList.tsx
Normal file
127
components/chapters/ChapterList.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* A modal listing an item's chapters. Each row shows the chapter name and its
|
||||||
|
* timestamp; the current chapter is highlighted. Tapping a row seeks to that
|
||||||
|
* chapter and closes the modal. Player-agnostic — the seek is injected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FlatList, Modal, Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import {
|
||||||
|
currentChapterIndex,
|
||||||
|
formatChapterTime,
|
||||||
|
sortedChapters,
|
||||||
|
} from "@/utils/chapters";
|
||||||
|
|
||||||
|
interface ChapterListProps {
|
||||||
|
visible: boolean;
|
||||||
|
chapters: ChapterInfo[] | null | undefined;
|
||||||
|
/** Current playback position in milliseconds (to highlight the row). */
|
||||||
|
currentPositionMs: number;
|
||||||
|
/** Seek the player to this millisecond position. */
|
||||||
|
onSeek: (positionMs: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChapterList({
|
||||||
|
visible,
|
||||||
|
chapters,
|
||||||
|
currentPositionMs,
|
||||||
|
onSeek,
|
||||||
|
onClose,
|
||||||
|
}: ChapterListProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const entries = sortedChapters(chapters);
|
||||||
|
const activeIndex = currentChapterIndex(currentPositionMs, chapters);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType='slide'
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
backgroundColor: "#0009",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
maxHeight: "70%",
|
||||||
|
paddingBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: "#fff", fontSize: 17, fontWeight: "700" }}>
|
||||||
|
{t("chapters.title")}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
hitSlop={10}
|
||||||
|
accessibilityRole='button'
|
||||||
|
accessibilityLabel={t("chapters.close")}
|
||||||
|
>
|
||||||
|
<Ionicons name='close' size={24} color='#fff' />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<FlatList
|
||||||
|
data={entries}
|
||||||
|
keyExtractor={(item, index) => `${item.positionMs}-${index}`}
|
||||||
|
renderItem={({ item, index }) => {
|
||||||
|
const positionMs = item.positionMs;
|
||||||
|
const isActive = index === activeIndex;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
onSeek(positionMs);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
backgroundColor: isActive ? "#a855f733" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: isActive ? "#a855f7" : "#fff",
|
||||||
|
fontSize: 15,
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{item.chapter.Name ||
|
||||||
|
t("chapters.chapter_number", { number: index + 1 })}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: "#999", fontSize: 13, marginLeft: 12 }}>
|
||||||
|
{formatChapterTime(positionMs)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
components/chapters/ChapterTicks.tsx
Normal file
52
components/chapters/ChapterTicks.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Chapter tick marks drawn as an absolute overlay over a progress slider.
|
||||||
|
* Renders nothing for media with one or zero chapters. `pointerEvents: "none"`
|
||||||
|
* so the slider underneath still receives touches.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { chapterMarkers } from "@/utils/chapters";
|
||||||
|
|
||||||
|
interface ChapterTicksProps {
|
||||||
|
chapters: ChapterInfo[] | null | undefined;
|
||||||
|
/** Total media duration in milliseconds. */
|
||||||
|
durationMs: number;
|
||||||
|
/** Tick colour. */
|
||||||
|
color?: string;
|
||||||
|
/** Tick height in px — slightly less than the slider track thickness. */
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChapterTicks({
|
||||||
|
chapters,
|
||||||
|
durationMs,
|
||||||
|
color = "#fff",
|
||||||
|
height = 6,
|
||||||
|
}: ChapterTicksProps) {
|
||||||
|
const markers = chapterMarkers(chapters, durationMs);
|
||||||
|
// One chapter (typically a single marker at 0) is not worth marking.
|
||||||
|
if (markers.length <= 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
pointerEvents='none'
|
||||||
|
style={{ position: "absolute", left: 0, right: 0, top: 0, bottom: 0 }}
|
||||||
|
>
|
||||||
|
{markers.map((marker, index) => (
|
||||||
|
<View
|
||||||
|
key={`${marker.positionMs}-${index}`}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: `${marker.percent}%`,
|
||||||
|
top: "50%",
|
||||||
|
marginTop: -height / 2,
|
||||||
|
height,
|
||||||
|
width: 1.5,
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
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 } 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";
|
||||||
@@ -13,6 +21,10 @@ import { TrickplayBubble } from "./TrickplayBubble";
|
|||||||
|
|
||||||
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;
|
||||||
@@ -61,6 +73,8 @@ interface BottomControlsProps {
|
|||||||
|
|
||||||
export const BottomControls: FC<BottomControlsProps> = ({
|
export const BottomControls: FC<BottomControlsProps> = ({
|
||||||
item,
|
item,
|
||||||
|
chapters,
|
||||||
|
durationMs,
|
||||||
showControls,
|
showControls,
|
||||||
isSliding,
|
isSliding,
|
||||||
showRemoteBubble,
|
showRemoteBubble,
|
||||||
@@ -89,7 +103,16 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
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;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -132,7 +155,18 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
<Text className='text-xs opacity-50'>{item?.Album}</Text>
|
<Text className='text-xs opacity-50'>{item?.Album}</Text>
|
||||||
)}
|
)}
|
||||||
</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'
|
||||||
|
accessibilityRole='button'
|
||||||
|
accessibilityLabel={t("chapters.open")}
|
||||||
|
>
|
||||||
|
<Ionicons name='list' size={24} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
@@ -212,6 +246,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
minimumValue={min}
|
minimumValue={min}
|
||||||
maximumValue={max}
|
maximumValue={max}
|
||||||
/>
|
/>
|
||||||
|
<ChapterTicks chapters={chapters} durationMs={durationMs} />
|
||||||
</View>
|
</View>
|
||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
@@ -219,6 +254,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<ChapterList
|
||||||
|
visible={chapterListVisible}
|
||||||
|
chapters={chapters}
|
||||||
|
currentPositionMs={currentTime}
|
||||||
|
onSeek={(ms) => handleSliderComplete(ms)}
|
||||||
|
onClose={() => setChapterListVisible(false)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -528,6 +528,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}
|
||||||
|
|||||||
@@ -53,5 +53,5 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// libmpv from Maven Central
|
// libmpv from Maven Central
|
||||||
implementation 'dev.jdtech.mpv:libmpv:1.0.0'
|
implementation 'dev.jdtech.mpv:libmpv:0.5.1'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
87
utils/chapters.test.ts
Normal file
87
utils/chapters.test.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
chapterMarkers,
|
||||||
|
currentChapterIndex,
|
||||||
|
formatChapterTime,
|
||||||
|
sortedChapters,
|
||||||
|
} from "./chapters";
|
||||||
|
|
||||||
|
// Helper: a ChapterInfo with a start in milliseconds.
|
||||||
|
const ch = (ms: number, name?: string) => ({
|
||||||
|
StartPositionTicks: ms * 10000,
|
||||||
|
Name: name,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chapterMarkers", () => {
|
||||||
|
test("maps chapters to position + percent", () => {
|
||||||
|
expect(chapterMarkers([ch(0), ch(30_000), ch(60_000)], 120_000)).toEqual([
|
||||||
|
{ positionMs: 0, percent: 0 },
|
||||||
|
{ positionMs: 30_000, percent: 25 },
|
||||||
|
{ positionMs: 60_000, percent: 50 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drops chapters past the duration", () => {
|
||||||
|
expect(chapterMarkers([ch(0), ch(200_000)], 120_000)).toEqual([
|
||||||
|
{ positionMs: 0, percent: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns [] when duration is 0 or chapters missing", () => {
|
||||||
|
expect(chapterMarkers([ch(0)], 0)).toEqual([]);
|
||||||
|
expect(chapterMarkers(null, 120_000)).toEqual([]);
|
||||||
|
expect(chapterMarkers(undefined, 120_000)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("excludes a chapter exactly at the duration", () => {
|
||||||
|
expect(chapterMarkers([ch(0), ch(120_000)], 120_000)).toEqual([
|
||||||
|
{ positionMs: 0, percent: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips chapters with no StartPositionTicks", () => {
|
||||||
|
expect(
|
||||||
|
chapterMarkers([{ StartPositionTicks: undefined }, ch(30_000)], 120_000),
|
||||||
|
).toEqual([{ positionMs: 30_000, percent: 25 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("currentChapterIndex", () => {
|
||||||
|
const chapters = [ch(0), ch(30_000), ch(60_000)];
|
||||||
|
test("returns the chapter containing the position", () => {
|
||||||
|
expect(currentChapterIndex(0, chapters)).toBe(0);
|
||||||
|
expect(currentChapterIndex(15_000, chapters)).toBe(0);
|
||||||
|
expect(currentChapterIndex(30_000, chapters)).toBe(1);
|
||||||
|
expect(currentChapterIndex(90_000, chapters)).toBe(2);
|
||||||
|
});
|
||||||
|
test("returns -1 before the first chapter and for no chapters", () => {
|
||||||
|
expect(currentChapterIndex(-5, chapters)).toBe(-1);
|
||||||
|
expect(currentChapterIndex(10_000, [])).toBe(-1);
|
||||||
|
expect(currentChapterIndex(10_000, null)).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sortedChapters", () => {
|
||||||
|
test("pairs each chapter with its ms start, sorted ascending", () => {
|
||||||
|
const a = ch(60_000, "C");
|
||||||
|
const b = ch(0, "A");
|
||||||
|
const c = ch(30_000, "B");
|
||||||
|
expect(sortedChapters([a, b, c])).toEqual([
|
||||||
|
{ chapter: b, positionMs: 0 },
|
||||||
|
{ chapter: c, positionMs: 30_000 },
|
||||||
|
{ chapter: a, positionMs: 60_000 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
test("returns [] for null/undefined", () => {
|
||||||
|
expect(sortedChapters(null)).toEqual([]);
|
||||||
|
expect(sortedChapters(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatChapterTime", () => {
|
||||||
|
test("formats m:ss and h:mm:ss", () => {
|
||||||
|
expect(formatChapterTime(65_000)).toBe("1:05");
|
||||||
|
expect(formatChapterTime(3_725_000)).toBe("1:02:05");
|
||||||
|
expect(formatChapterTime(-100)).toBe("0:00");
|
||||||
|
});
|
||||||
|
});
|
||||||
78
utils/chapters.ts
Normal file
78
utils/chapters.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Pure helpers for Jellyfin chapter markers. Dependency-free so they are
|
||||||
|
* unit-testable under `bun test`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { ticksToMs } from "@/utils/time";
|
||||||
|
|
||||||
|
export interface ChapterMarker {
|
||||||
|
/** Chapter start, in milliseconds. */
|
||||||
|
positionMs: number;
|
||||||
|
/** Chapter start as a percentage (0-100) of the media duration. */
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChapterEntry {
|
||||||
|
chapter: ChapterInfo;
|
||||||
|
/** Chapter start, in milliseconds. */
|
||||||
|
positionMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chapters paired with their millisecond start, sorted ascending by start. */
|
||||||
|
export const sortedChapters = (
|
||||||
|
chapters: ChapterInfo[] | null | undefined,
|
||||||
|
): ChapterEntry[] =>
|
||||||
|
(chapters ?? [])
|
||||||
|
.filter((c) => c.StartPositionTicks != null)
|
||||||
|
.map((chapter) => ({
|
||||||
|
chapter,
|
||||||
|
positionMs: ticksToMs(chapter.StartPositionTicks),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.positionMs - b.positionMs);
|
||||||
|
|
||||||
|
/** Chapter start positions in milliseconds, ascending. */
|
||||||
|
export const chapterStartsMs = (
|
||||||
|
chapters: ChapterInfo[] | null | undefined,
|
||||||
|
): number[] =>
|
||||||
|
(chapters ?? [])
|
||||||
|
.filter((c) => c.StartPositionTicks != null)
|
||||||
|
.map((c) => ticksToMs(c.StartPositionTicks))
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
/** Chapter markers within [0, durationMs]; empty when duration is unknown. */
|
||||||
|
export const chapterMarkers = (
|
||||||
|
chapters: ChapterInfo[] | null | undefined,
|
||||||
|
durationMs: number,
|
||||||
|
): ChapterMarker[] => {
|
||||||
|
if (durationMs <= 0) return [];
|
||||||
|
return chapterStartsMs(chapters)
|
||||||
|
.filter((ms) => ms >= 0 && ms < durationMs)
|
||||||
|
.map((ms) => ({ positionMs: ms, percent: (ms / durationMs) * 100 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Index of the chapter containing `positionMs`, or -1 if before the first. */
|
||||||
|
export const currentChapterIndex = (
|
||||||
|
positionMs: number,
|
||||||
|
chapters: ChapterInfo[] | null | undefined,
|
||||||
|
): number => {
|
||||||
|
const starts = chapterStartsMs(chapters);
|
||||||
|
let index = -1;
|
||||||
|
for (let i = 0; i < starts.length; i++) {
|
||||||
|
if (positionMs >= starts[i]) index = i;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `m:ss` (or `h:mm:ss` past an hour) label for a millisecond position. */
|
||||||
|
export const formatChapterTime = (positionMs: number): string => {
|
||||||
|
const total = Math.max(0, Math.floor(positionMs / 1000));
|
||||||
|
const hours = Math.floor(total / 3600);
|
||||||
|
const minutes = Math.floor((total % 3600) / 60);
|
||||||
|
const seconds = total % 60;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return hours > 0
|
||||||
|
? `${hours}:${pad(minutes)}:${pad(seconds)}`
|
||||||
|
: `${minutes}:${pad(seconds)}`;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user