Compare commits

..

5 Commits

Author SHA1 Message Date
Lance Chant
6b90d3e868 removing unused variable
removed unused variable

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-02-13 16:32:10 +02:00
Lance Chant
de81b36725 Revert "fix: text ui scaling"
This reverts commit 9c0de94247.
2026-02-13 16:30:36 +02:00
Lance Chant
b83b5b0bbb feat: adding support for local intros
Adding logic to be able to play local intros before content plays

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-02-13 16:23:32 +02:00
Lance Chant
ec92e98b16 Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2026-02-13 14:41:26 +02:00
Lance Chant
9c0de94247 fix: text ui scaling
Made text UI scaling follow OS level scailing to a limit to stop
overlapping issues

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-01-19 14:59:11 +02:00
10 changed files with 278 additions and 164 deletions

View File

@@ -220,10 +220,7 @@ jobs:
const jobMappings = {
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
'iOS': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone'],
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)', 'build-ios-phone-unsigned'],
'tvOS': ['🍎 Build tvOS IPA', 'build-ios-tv'],
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)', 'build-ios-tv-unsigned']
'iOS Phone': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone']
};
// Create individual status for each job
@@ -356,12 +353,10 @@ jobs:
// Process each expected build target individually
const buildTargets = [
{ name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
{ name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS Phone', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i },
{ name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Phone Unsigned', artifactPattern: /ios.*phone.*unsigned/i },
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /ios.*tv.*ipa(?!.*unsigned)/i },
{ name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
{ name: 'Android Phone', platform: '🤖', device: '📱', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
{ name: 'Android TV', platform: '🤖', device: '📺', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
{ name: 'iOS Phone', platform: '🍎', device: '📱', statusKey: 'iOS Phone', artifactPattern: /ios.*phone/i },
{ name: 'iOS TV', platform: '🍎', device: '📺', statusKey: 'iOS TV', artifactPattern: /ios.*tv/i }
];
for (const target of buildTargets) {
@@ -376,26 +371,16 @@ jobs:
let status = '⏳ Pending';
let downloadLink = '*Waiting for build...*';
if (matchingStatus) {
// Special case for iOS TV - show as disabled
if (target.name === 'iOS TV') {
status = '💤 Disabled';
downloadLink = '*Disabled for now*';
} else if (matchingStatus) {
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
status = '✅ Complete';
const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`;
const fileType = target.name.includes('Android') ? 'APK' : 'IPA';
// Format file size
const sizeInMB = (matchingArtifact.size_in_bytes / (1024 * 1024)).toFixed(1);
const sizeInfo = `(${sizeInMB} MB)`;
// Calculate build duration
let durationInfo = '';
if (matchingStatus.started_at && matchingStatus.completed_at) {
const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
const durationMin = Math.floor(durationMs / 60000);
const durationSec = Math.floor((durationMs % 60000) / 1000);
durationInfo = ` - ${durationMin}m ${durationSec}s`;
}
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
downloadLink = `[📥 Download ${fileType}](${directLink})`;
} else if (matchingStatus.conclusion === 'failure') {
status = `❌ [Failed](${matchingStatus.url})`;
downloadLink = '*Build failed*';
@@ -423,7 +408,7 @@ jobs:
}
}
commentBody += `| ${target.platform} ${target.name} | ${target.device} | ${status} | ${downloadLink} |\n`;
commentBody += `| ${target.platform} ${target.name.split(' ')[0]} | ${target.device} ${target.name.split(' ')[1]} | ${status} | ${downloadLink} |\n`;
}
commentBody += `\n`;

View File

@@ -299,123 +299,67 @@ jobs:
path: build/*.ipa
retention-days: 7
build-ios-tv:
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
runs-on: macos-26
name: 🍎 Build tvOS IPA
permissions:
contents: read
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 🛠️ Generate project files
run: bun run prebuild:tv
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with:
xcode-version: "26.2"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 🚀 Build iOS app
env:
EXPO_TV: 1
run: eas build -p ios --local --non-interactive
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
path: build-*.ipa
retention-days: 7
build-ios-tv-unsigned:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: macos-26
name: 🍎 Build tvOS IPA (Unsigned)
permissions:
contents: read
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 🛠️ Generate project files
run: bun run prebuild:tv
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with:
xcode-version: "26.2"
- name: 🚀 Build iOS app
env:
EXPO_TV: 1
run: bun run ios:unsigned-build:tv ${{ github.event_name == 'pull_request' && '-- --verbose' || '' }}
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
path: build/*.ipa
retention-days: 7
# Disabled for now - uncomment when ready to build iOS TV
# build-ios-tv:
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
# runs-on: macos-26
# name: 🍎 Build iOS IPA (TV)
# permissions:
# contents: read
#
# steps:
# - name: 📥 Checkout code
# uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
# with:
# ref: ${{ github.event.pull_request.head.sha || github.sha }}
# fetch-depth: 0
# submodules: recursive
# show-progress: false
#
# - name: 🍞 Setup Bun
# uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
# with:
# bun-version: latest
#
# - name: 💾 Cache Bun dependencies
# uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
# with:
# path: ~/.bun/install/cache
# key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
# restore-keys: |
# ${{ runner.os }}-bun-cache
#
# - name: 📦 Install dependencies and reload submodules
# run: |
# bun install --frozen-lockfile
# bun run submodule-reload
#
# - name: 🛠️ Generate project files
# run: bun run prebuild:tv
#
# - name: 🔧 Setup Xcode
# uses: maxim-lobanov/setup-xcode@v1
# with:
# xcode-version: '26.0.1'
#
# - name: 🏗️ Setup EAS
# uses: expo/expo-github-action@main
# with:
# eas-version: latest
# token: ${{ secrets.EXPO_TOKEN }}
# eas-cache: true
#
# - name: 🚀 Build iOS app
# env:
# EXPO_TV: 1
# run: eas build -p ios --local --non-interactive
#
# - name: 📅 Set date tag
# run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
#
# - name: 📤 Upload IPA artifact
# uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
# with:
# name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
# path: build-*.ipa
# retention-days: 7

View File

@@ -27,13 +27,6 @@ jobs:
with:
fetch-depth: 0
- name: 🔑 Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.CROWDIN_APP_ID }}
private-key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }}
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0
with:
@@ -53,6 +46,6 @@ jobs:
# Commit customization
commit_message: "feat(i18n): update translations from Crowdin"
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -28,6 +28,7 @@ import {
} from "@/components/video-player/controls/utils/playback-speed-settings";
import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic";
import { useIntroPlayback } from "@/hooks/useIntroPlayback";
import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
@@ -55,7 +56,7 @@ import {
} from "@/utils/jellyfin/subtitleUtils";
import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import { msToTicks, ticksToMs, ticksToSeconds } from "@/utils/time";
export default function page() {
const videoRef = useRef<MpvPlayerViewRef>(null);
@@ -87,6 +88,8 @@ export default function page() {
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
// Track whether we've already triggered completion for the current intro
const introCompletionTriggered = useSharedValue(false);
const VolumeManager = Platform.isTV
? null
: require("react-native-volume-manager");
@@ -149,6 +152,14 @@ export default function page() {
isError: false,
});
// Intro playback hook - manages intro video playback before main content
const { intros, currentIntro, isPlayingIntro, skipAllIntros } =
useIntroPlayback({
api,
itemId: item?.Id || null,
userId: user?.Id,
});
// Resolve audio index: use URL param if provided, otherwise use stored index for offline playback
const audioIndex = useMemo(() => {
if (audioIndexFromUrl !== undefined) {
@@ -247,6 +258,9 @@ export default function page() {
isError: false,
});
// Intro stream state - separate from main content stream
const [introStream, setIntroStream] = useState<Stream | null>(null);
useEffect(() => {
const fetchStreamData = async () => {
setStreamStatus({ isLoading: true, isError: false });
@@ -327,6 +341,57 @@ export default function page() {
downloadedItem,
]);
// Fetch intro stream when current intro changes
useEffect(() => {
const fetchIntroStreamData = async () => {
// Don't fetch intro stream if offline or no current intro
if (offline || !currentIntro?.Id || !api || !user?.Id) {
setIntroStream(null);
return;
}
try {
const res = await getStreamUrl({
api,
item: currentIntro,
startTimeTicks: 0, // Always start from beginning for intros
userId: user.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: undefined,
subtitleStreamIndex: subtitleIndex,
deviceProfile: generateDeviceProfile(),
});
if (!res) return;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
console.error("Failed to get intro stream URL");
return;
}
setIntroStream({ mediaSource, sessionId, url });
} catch (error) {
console.error("Failed to fetch intro stream:", error);
}
};
fetchIntroStreamData();
}, [
currentIntro,
api,
user?.Id,
audioIndex,
bitrateValue,
subtitleIndex,
offline,
]);
// Reset intro completion flag when a new intro starts playing
useEffect(() => {
if (isPlayingIntro) {
introCompletionTriggered.value = false;
}
}, [isPlayingIntro, currentIntro]);
useEffect(() => {
if (!stream || !api || offline) return;
const reportPlaybackStart = async () => {
@@ -480,6 +545,21 @@ export default function page() {
lastUrlUpdateTime.value = now;
}
// Handle intro completion - check if intro has reached its end
if (isPlayingIntro && currentIntro) {
const introDuration = ticksToMs(currentIntro.RunTimeTicks || 0);
// Check if we're near the end of the intro (within 1000ms buffer)
// Use a larger buffer to ensure reliable detection even with short intros
// or if MPV doesn't fire progress callbacks frequently
if (currentTime >= introDuration - 1000) {
// Only trigger once per intro to avoid multiple calls
if (!introCompletionTriggered.value) {
introCompletionTriggered.value = true;
skipAllIntros();
}
}
}
if (!item?.Id) return;
playbackManager.reportPlaybackProgress(
@@ -496,6 +576,9 @@ export default function page() {
isSeeking,
isPlaybackStopped,
isBuffering,
isPlayingIntro,
currentIntro,
skipAllIntros,
],
);
@@ -506,9 +589,11 @@ export default function page() {
/** Build video source config for MPV */
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
if (!stream?.url) return undefined;
// Use intro stream if playing intro, otherwise use main content stream
const activeStream = isPlayingIntro ? introStream : stream;
if (!activeStream?.url) return undefined;
const mediaSource = stream.mediaSource;
const mediaSource = activeStream.mediaSource;
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
// Get external subtitle URLs
@@ -544,14 +629,17 @@ export default function page() {
);
// Calculate start position directly here to avoid timing issues
const startTicks = playbackPositionFromUrl
? Number.parseInt(playbackPositionFromUrl, 10)
: (item?.UserData?.PlaybackPositionTicks ?? 0);
// For intros, always start from 0
const startTicks = isPlayingIntro
? 0
: playbackPositionFromUrl
? Number.parseInt(playbackPositionFromUrl, 10)
: (item?.UserData?.PlaybackPositionTicks ?? 0);
const startPos = ticksToSeconds(startTicks);
// Build source config - headers only needed for online streaming
const source: MpvVideoSource = {
url: stream.url,
url: activeStream.url,
startPosition: startPos,
autoplay: true,
initialSubtitleId,
@@ -574,6 +662,8 @@ export default function page() {
}, [
stream?.url,
stream?.mediaSource,
introStream?.url,
introStream?.mediaSource,
item?.UserData?.PlaybackPositionTicks,
playbackPositionFromUrl,
api?.basePath,
@@ -581,6 +671,7 @@ export default function page() {
subtitleIndex,
audioIndex,
offline,
isPlayingIntro,
]);
const volumeUpCb = useCallback(async () => {
@@ -993,6 +1084,9 @@ export default function page() {
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
isPlayingIntro={isPlayingIntro}
skipAllIntros={skipAllIntros}
intros={intros}
/>
)}
</View>

View File

@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "streamyfin",

View File

@@ -57,6 +57,11 @@ interface BottomControlsProps {
minutes: number;
seconds: number;
};
// Intro playback props
isPlayingIntro?: boolean;
skipAllIntros?: () => void;
intros?: BaseItemDto[];
}
export const BottomControls: FC<BottomControlsProps> = ({
@@ -87,6 +92,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
trickPlayUrl,
trickplayInfo,
time,
isPlayingIntro = false,
skipAllIntros,
intros = [],
}) => {
const { settings } = useSettings();
const insets = useSafeAreaInsets();
@@ -133,6 +141,14 @@ export const BottomControls: FC<BottomControlsProps> = ({
)}
</View>
<View className='flex flex-row space-x-2 shrink-0'>
{/* Skip Intro button - shows when playing intro videos */}
{isPlayingIntro && intros.length > 0 && skipAllIntros && (
<SkipButton
showButton={true}
onPress={skipAllIntros}
buttonText='Skip Intro'
/>
)}
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}

View File

@@ -72,6 +72,10 @@ interface Props {
getTechnicalInfo?: () => Promise<TechnicalInfo>;
playMethod?: "DirectPlay" | "DirectStream" | "Transcode";
transcodeReasons?: string[];
// Intro playback props
isPlayingIntro?: boolean;
skipAllIntros?: () => void;
intros?: BaseItemDto[];
}
export const Controls: FC<Props> = ({
@@ -101,6 +105,9 @@ export const Controls: FC<Props> = ({
getTechnicalInfo,
playMethod,
transcodeReasons,
isPlayingIntro = false,
skipAllIntros,
intros = [],
}) => {
const offline = useOfflineMode();
const { settings, updateSettings } = useSettings();
@@ -554,6 +561,9 @@ export const Controls: FC<Props> = ({
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime}
isPlayingIntro={isPlayingIntro}
skipAllIntros={skipAllIntros}
intros={intros}
/>
</Animated.View>
</>

46
hooks/useIntroPlayback.ts Normal file
View File

@@ -0,0 +1,46 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useState } from "react";
import { getIntros } from "@/utils/intros";
interface UseIntroPlaybackProps {
api: Api | null;
itemId: string | null;
userId?: string;
}
export function useIntroPlayback({
api,
itemId,
userId,
}: UseIntroPlaybackProps) {
const [intros, setIntros] = useState<BaseItemDto[]>([]);
const [isPlayingIntro, setIsPlayingIntro] = useState(false);
useEffect(() => {
async function fetchIntros() {
if (!api || !itemId) return;
const introItems = await getIntros(api, itemId, userId);
setIntros(introItems);
// Set isPlayingIntro to true when intros are available
setIsPlayingIntro(introItems.length > 0);
}
fetchIntros();
}, [api, itemId, userId]);
// Only play the first intro if intros are available.. might be nice to configure this at some point with tags or something 🤷‍♂️
const currentIntro = intros.length > 0 ? intros[0] : null;
const skipAllIntros = () => {
setIsPlayingIntro(false);
};
return {
intros,
currentIntro,
isPlayingIntro,
skipAllIntros,
};
}

View File

@@ -15,7 +15,6 @@
"android:tv": "cross-env EXPO_TV=1 expo run:android",
"build:android:local": "cd android && cross-env NODE_ENV=production ./gradlew assembleRelease",
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
"prepare": "husky",
"typecheck": "node scripts/typecheck.js",
"check": "biome check . --max-diagnostics 1000",

28
utils/intros.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
/**
* Fetches intro items for a given media item using the Jellyfin SDK
* @param api - The Jellyfin API instance
* @param itemId - The ID of the media item
* @param userId - Optional user ID
* @returns Promise<BaseItemDto[]> - Array of intro items
*/
export async function getIntros(
api: Api,
itemId: string,
userId?: string,
): Promise<BaseItemDto[]> {
try {
const response = await getUserLibraryApi(api).getIntros({
itemId,
userId,
});
return response.data.Items || [];
} catch (error) {
console.error("Error fetching intros:", error);
return [];
}
}