mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-27 17:18:29 +01:00
Compare commits
11 Commits
renovate/d
...
feat/chapt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
558fb41833 | ||
|
|
98b90f5bdb | ||
|
|
5f64ce49c0 | ||
|
|
8d33aa3ba1 | ||
|
|
1545790528 | ||
|
|
11ec778bd8 | ||
|
|
0c6ef5cbda | ||
|
|
1e14c7ec46 | ||
|
|
c8ddb9a892 | ||
|
|
9ee71a002d | ||
|
|
c950408bdb |
16
.github/workflows/build-apps.yml
vendored
16
.github/workflows/build-apps.yml
vendored
@@ -318,12 +318,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
@@ -339,7 +339,7 @@ jobs:
|
|||||||
run: bun run prebuild:tv
|
run: bun run prebuild:tv
|
||||||
|
|
||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
xcode-version: "26.2"
|
xcode-version: "26.2"
|
||||||
|
|
||||||
@@ -359,7 +359,7 @@ jobs:
|
|||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: 📤 Upload IPA artifact
|
- name: 📤 Upload IPA artifact
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
|
name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
|
||||||
path: build-*.ipa
|
path: build-*.ipa
|
||||||
@@ -384,12 +384,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
@@ -405,7 +405,7 @@ jobs:
|
|||||||
run: bun run prebuild:tv
|
run: bun run prebuild:tv
|
||||||
|
|
||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
xcode-version: "26.2"
|
xcode-version: "26.2"
|
||||||
|
|
||||||
@@ -418,7 +418,7 @@ jobs:
|
|||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: 📤 Upload IPA artifact
|
- name: 📤 Upload IPA artifact
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
|
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
|
||||||
path: build/*.ipa
|
path: build/*.ipa
|
||||||
|
|||||||
6
.github/workflows/ci-codeql.yml
vendored
6
.github/workflows/ci-codeql.yml
vendored
@@ -27,13 +27,13 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api/user-library-api";
|
||||||
import { type Href } from "expo-router";
|
import { type Href } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -195,9 +196,30 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const downloadDetailsPromises = items.map(async (item) => {
|
const downloadDetailsPromises = items.map(async (item) => {
|
||||||
|
// Ensure the snapshot we store offline carries the Chapters array.
|
||||||
|
// Page-level fetches sometimes use a fields filter that omits it; the
|
||||||
|
// offline player would then render no chapter ticks / list.
|
||||||
|
let itemForDownload = item;
|
||||||
|
if (!itemForDownload.Chapters && itemForDownload.Id) {
|
||||||
|
try {
|
||||||
|
const enriched = await getUserLibraryApi(api).getItem({
|
||||||
|
itemId: itemForDownload.Id,
|
||||||
|
userId: user.Id!,
|
||||||
|
});
|
||||||
|
if (enriched.data) {
|
||||||
|
itemForDownload = enriched.data;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
"[DownloadItem] failed to refresh item for Chapters, falling back to original",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { mediaSource, audioIndex, subtitleIndex } =
|
const { mediaSource, audioIndex, subtitleIndex } =
|
||||||
itemsNotDownloaded.length > 1
|
itemsNotDownloaded.length > 1
|
||||||
? getDefaultPlaySettings(item, settings!)
|
? getDefaultPlaySettings(itemForDownload, settings!)
|
||||||
: {
|
: {
|
||||||
mediaSource: selectedOptions?.mediaSource,
|
mediaSource: selectedOptions?.mediaSource,
|
||||||
audioIndex: selectedOptions?.audioIndex,
|
audioIndex: selectedOptions?.audioIndex,
|
||||||
@@ -206,7 +228,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
const downloadDetails = await getDownloadUrl({
|
const downloadDetails = await getDownloadUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item: itemForDownload,
|
||||||
userId: user.Id!,
|
userId: user.Id!,
|
||||||
mediaSource: mediaSource!,
|
mediaSource: mediaSource!,
|
||||||
audioStreamIndex: audioIndex ?? -1,
|
audioStreamIndex: audioIndex ?? -1,
|
||||||
@@ -218,7 +240,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
url: downloadDetails?.url,
|
url: downloadDetails?.url,
|
||||||
item,
|
item: itemForDownload,
|
||||||
mediaSource: downloadDetails?.mediaSource,
|
mediaSource: downloadDetails?.mediaSource,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
194
components/chapters/ChapterList.tsx
Normal file
194
components/chapters/ChapterList.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* 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 { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import {
|
||||||
|
type ChapterEntry,
|
||||||
|
chapterStartsMs,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW_HEIGHT = 48;
|
||||||
|
|
||||||
|
export function ChapterList({
|
||||||
|
visible,
|
||||||
|
chapters,
|
||||||
|
currentPositionMs,
|
||||||
|
onSeek,
|
||||||
|
onClose,
|
||||||
|
}: ChapterListProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
||||||
|
|
||||||
|
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
||||||
|
// Memoize starts so currentChapterIndex computation doesn't re-sort/filter
|
||||||
|
// every tick — chapters is the only input that drives the underlying array.
|
||||||
|
const starts = useMemo(() => chapterStartsMs(chapters), [chapters]);
|
||||||
|
const activeIndex = useMemo(() => {
|
||||||
|
let idx = -1;
|
||||||
|
for (let i = 0; i < starts.length; i++) {
|
||||||
|
if (currentPositionMs >= starts[i]) idx = i;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return idx;
|
||||||
|
}, [currentPositionMs, starts]);
|
||||||
|
|
||||||
|
// FlatList.initialScrollIndex only fires at first mount; <Modal> keeps its
|
||||||
|
// children mounted across visible toggles, so subsequent opens never scroll.
|
||||||
|
// Trigger an imperative scroll each time the sheet becomes visible.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || activeIndex < 0 || entries.length === 0) return;
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
|
listRef.current?.scrollToIndex({
|
||||||
|
index: activeIndex,
|
||||||
|
animated: false,
|
||||||
|
viewPosition: 0.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [visible, activeIndex, entries.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType='slide'
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<Pressable onPress={onClose} style={styles.backdrop}>
|
||||||
|
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t("chapters.title")}</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
hitSlop={10}
|
||||||
|
accessibilityRole='button'
|
||||||
|
accessibilityLabel={t("chapters.close")}
|
||||||
|
>
|
||||||
|
<Ionicons name='close' size={24} color={Colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<FlatList
|
||||||
|
ref={listRef}
|
||||||
|
data={entries}
|
||||||
|
keyExtractor={(item, index) => `${item.positionMs}-${index}`}
|
||||||
|
getItemLayout={(_, index) => ({
|
||||||
|
length: ROW_HEIGHT,
|
||||||
|
offset: ROW_HEIGHT * index,
|
||||||
|
index,
|
||||||
|
})}
|
||||||
|
onScrollToIndexFailed={(info) => {
|
||||||
|
// Required when getItemLayout is provided and the target index
|
||||||
|
// is outside the currently rendered window. Fallback to an
|
||||||
|
// offset-based scroll, then retry the precise scroll once a
|
||||||
|
// frame has elapsed.
|
||||||
|
listRef.current?.scrollToOffset({
|
||||||
|
offset: info.averageItemLength * info.index,
|
||||||
|
animated: false,
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
listRef.current?.scrollToIndex({
|
||||||
|
index: info.index,
|
||||||
|
animated: false,
|
||||||
|
viewPosition: 0.5,
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
}}
|
||||||
|
renderItem={({ item, index }) => {
|
||||||
|
const positionMs = item.positionMs;
|
||||||
|
const isActive = index === activeIndex;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
onSeek(positionMs);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
style={[
|
||||||
|
styles.row,
|
||||||
|
isActive && { backgroundColor: `${Colors.primary}33` },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.rowText,
|
||||||
|
{ color: isActive ? Colors.primary : Colors.text },
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{item.chapter.Name ||
|
||||||
|
t("chapters.chapter_number", { number: index + 1 })}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.rowTime}>
|
||||||
|
{formatChapterTime(positionMs)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
backdrop: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
|
},
|
||||||
|
sheet: {
|
||||||
|
backgroundColor: Colors.background,
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
maxHeight: "70%",
|
||||||
|
paddingBottom: 24,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: Colors.text,
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
height: ROW_HEIGHT,
|
||||||
|
},
|
||||||
|
rowText: {
|
||||||
|
fontSize: 15,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
rowTime: {
|
||||||
|
color: Colors.icon,
|
||||||
|
fontSize: 13,
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
85
components/chapters/ChapterTicks.tsx
Normal file
85
components/chapters/ChapterTicks.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* 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 { useState } from "react";
|
||||||
|
import { type LayoutChangeEvent, PixelRatio, View } from "react-native";
|
||||||
|
import type { ChapterMarker } from "@/utils/chapters";
|
||||||
|
|
||||||
|
interface ChapterTicksProps {
|
||||||
|
/** Pre-computed markers (caller memoizes — avoids double-computing here). */
|
||||||
|
markers: ChapterMarker[];
|
||||||
|
/** Tick colour. */
|
||||||
|
color?: string;
|
||||||
|
/** Tick height in px — slightly less than the slider track thickness. */
|
||||||
|
height?: number;
|
||||||
|
/** Tick width in px — integer to avoid sub-pixel anti-aliasing. */
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChapterTicks({
|
||||||
|
markers,
|
||||||
|
// Semi-transparent black contrasts against both the filled progress
|
||||||
|
// (#fff) and the unfilled track (rgba(255,255,255,0.2)) so the ticks
|
||||||
|
// stay visible across the whole bar as playback advances.
|
||||||
|
color = "rgba(0,0,0,0.55)",
|
||||||
|
height = 14,
|
||||||
|
width = 2,
|
||||||
|
}: ChapterTicksProps) {
|
||||||
|
// Hooks must run unconditionally — keep them before any early return.
|
||||||
|
const [sliderWidth, setSliderWidth] = useState(0);
|
||||||
|
|
||||||
|
const handleLayout = (e: LayoutChangeEvent) => {
|
||||||
|
setSliderWidth(e.nativeEvent.layout.width);
|
||||||
|
};
|
||||||
|
|
||||||
|
// One chapter (typically a single marker at 0) is not worth marking.
|
||||||
|
if (markers.length <= 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
pointerEvents='none'
|
||||||
|
onLayout={handleLayout}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
// Let ticks taller than this container bleed beyond its bounds.
|
||||||
|
overflow: "visible",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sliderWidth > 0 &&
|
||||||
|
markers
|
||||||
|
// Skip the leading 0ms marker — it overlaps the slider start and
|
||||||
|
// adds visual noise at an already-rendered boundary.
|
||||||
|
.filter((marker) => marker.positionMs > 0)
|
||||||
|
.map((marker, index) => {
|
||||||
|
// Align both the position AND the width onto the device's
|
||||||
|
// physical pixel grid. Without this, fractional dp values land
|
||||||
|
// at different sub-pixel fractions per tick — Android samples
|
||||||
|
// each one differently and some ticks render visibly thicker.
|
||||||
|
const centerDp = (marker.percent / 100) * sliderWidth;
|
||||||
|
const left = PixelRatio.roundToNearestPixel(centerDp - width / 2);
|
||||||
|
const snappedWidth = PixelRatio.roundToNearestPixel(width);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={`${marker.positionMs}-${index}`}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left,
|
||||||
|
top: "50%",
|
||||||
|
marginTop: -height / 2,
|
||||||
|
height,
|
||||||
|
width: snappedWidth,
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
/>
|
/>
|
||||||
</View>
|
{/*
|
||||||
<Text
|
* 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={{
|
style={{
|
||||||
marginTop: 30,
|
position: "absolute",
|
||||||
fontSize: 16,
|
left: 4,
|
||||||
|
bottom: 3,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
paddingHorizontal: 3,
|
||||||
|
paddingVertical: 1,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.55)",
|
||||||
|
maxWidth: tileWidth - 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
{chapterName ? (
|
||||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
<Text
|
||||||
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 7,
|
||||||
|
opacity: 0.85,
|
||||||
|
lineHeight: 9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chapterName}
|
||||||
</Text>
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: "600",
|
||||||
|
lineHeight: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{timeStr}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@
|
|||||||
"@types/react": "19.1.17",
|
"@types/react": "19.1.17",
|
||||||
"@types/react-test-renderer": "19.1.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"expo-doctor": "1.18.22",
|
"expo-doctor": "1.19.7",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "17.0.5",
|
||||||
"react-test-renderer": "19.2.3",
|
"react-test-renderer": "19.2.3",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
138
utils/chapters.test.ts
Normal file
138
utils/chapters.test.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
chapterMarkers,
|
||||||
|
chapterNameAt,
|
||||||
|
chapterStartsMs,
|
||||||
|
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("chapterStartsMs", () => {
|
||||||
|
test("returns sorted ms positions", () => {
|
||||||
|
expect(chapterStartsMs([ch(60_000), ch(0), ch(30_000)])).toEqual([
|
||||||
|
0, 30_000, 60_000,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips entries without StartPositionTicks", () => {
|
||||||
|
expect(
|
||||||
|
chapterStartsMs([ch(30_000), { StartPositionTicks: undefined }, ch(0)]),
|
||||||
|
).toEqual([0, 30_000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns [] for null/undefined/empty", () => {
|
||||||
|
expect(chapterStartsMs(null)).toEqual([]);
|
||||||
|
expect(chapterStartsMs(undefined)).toEqual([]);
|
||||||
|
expect(chapterStartsMs([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chapterNameAt", () => {
|
||||||
|
const named = [
|
||||||
|
{ StartPositionTicks: 0, Name: "Intro" },
|
||||||
|
{ StartPositionTicks: 30_000 * 10000, Name: "Action" },
|
||||||
|
{ StartPositionTicks: 60_000 * 10000, Name: "Outro" },
|
||||||
|
];
|
||||||
|
|
||||||
|
test("returns the chapter name for the active position", () => {
|
||||||
|
expect(chapterNameAt(0, named)).toBe("Intro");
|
||||||
|
expect(chapterNameAt(15_000, named)).toBe("Intro");
|
||||||
|
expect(chapterNameAt(45_000, named)).toBe("Action");
|
||||||
|
expect(chapterNameAt(90_000, named)).toBe("Outro");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null before the first chapter", () => {
|
||||||
|
expect(chapterNameAt(-1, named)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for null/undefined/empty chapters", () => {
|
||||||
|
expect(chapterNameAt(10_000, null)).toBeNull();
|
||||||
|
expect(chapterNameAt(10_000, undefined)).toBeNull();
|
||||||
|
expect(chapterNameAt(10_000, [])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when the active chapter has no Name", () => {
|
||||||
|
expect(chapterNameAt(15_000, [ch(0), ch(30_000)])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
90
utils/chapters.ts
Normal file
90
utils/chapters.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Name of the chapter containing `positionMs`, or null if none / unnamed. */
|
||||||
|
export const chapterNameAt = (
|
||||||
|
positionMs: number,
|
||||||
|
chapters: ChapterInfo[] | null | undefined,
|
||||||
|
): string | null => {
|
||||||
|
const idx = currentChapterIndex(positionMs, chapters);
|
||||||
|
if (idx < 0) return null;
|
||||||
|
const sorted = sortedChapters(chapters);
|
||||||
|
const name = sorted[idx]?.chapter.Name;
|
||||||
|
return name && name.length > 0 ? name : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `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