Compare commits

..

1 Commits

Author SHA1 Message Date
Uruk
a44fefbccb feat(build): enables tvos builds and artifact reporting
Enables tvOS builds, including unsigned variants, and integrates them into the artifact reporting workflow.

This change re-enables the tvOS build jobs, adds a job for unsigned tvOS builds, and updates the artifact reporting workflow to include these new build targets. The artifact comment now displays download links, file sizes, and build durations for all available builds. Also updates workflow dependencies.
2026-02-17 18:50:27 +01:00
15 changed files with 216 additions and 589 deletions

View File

@@ -220,7 +220,10 @@ jobs:
const jobMappings = { const jobMappings = {
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'], 'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'], 'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
'iOS Phone': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone'] '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']
}; };
// Create individual status for each job // Create individual status for each job
@@ -353,10 +356,12 @@ jobs:
// Process each expected build target individually // Process each expected build target individually
const buildTargets = [ const buildTargets = [
{ name: 'Android Phone', platform: '🤖', device: '📱', statusKey: 'Android Phone', artifactPattern: /android.*phone/i }, { name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
{ name: 'Android TV', platform: '🤖', device: '📺', statusKey: 'Android TV', artifactPattern: /android.*tv/i }, { name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
{ name: 'iOS Phone', platform: '🍎', device: '📱', statusKey: 'iOS Phone', artifactPattern: /ios.*phone/i }, { name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS Phone', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i },
{ name: 'iOS TV', platform: '🍎', device: '📺', statusKey: 'iOS TV', artifactPattern: /ios.*tv/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 }
]; ];
for (const target of buildTargets) { for (const target of buildTargets) {
@@ -371,16 +376,26 @@ jobs:
let status = '⏳ Pending'; let status = '⏳ Pending';
let downloadLink = '*Waiting for build...*'; let downloadLink = '*Waiting for build...*';
// Special case for iOS TV - show as disabled if (matchingStatus) {
if (target.name === 'iOS TV') {
status = '💤 Disabled';
downloadLink = '*Disabled for now*';
} else if (matchingStatus) {
if (matchingStatus.conclusion === 'success' && matchingArtifact) { if (matchingStatus.conclusion === 'success' && matchingArtifact) {
status = '✅ Complete'; status = '✅ Complete';
const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`; 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'; const fileType = target.name.includes('Android') ? 'APK' : 'IPA';
downloadLink = `[📥 Download ${fileType}](${directLink})`;
// 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}`;
} else if (matchingStatus.conclusion === 'failure') { } else if (matchingStatus.conclusion === 'failure') {
status = `❌ [Failed](${matchingStatus.url})`; status = `❌ [Failed](${matchingStatus.url})`;
downloadLink = '*Build failed*'; downloadLink = '*Build failed*';
@@ -408,7 +423,7 @@ jobs:
} }
} }
commentBody += `| ${target.platform} ${target.name.split(' ')[0]} | ${target.device} ${target.name.split(' ')[1]} | ${status} | ${downloadLink} |\n`; commentBody += `| ${target.platform} ${target.name} | ${target.device} | ${status} | ${downloadLink} |\n`;
} }
commentBody += `\n`; commentBody += `\n`;

View File

@@ -299,67 +299,123 @@ jobs:
path: build/*.ipa path: build/*.ipa
retention-days: 7 retention-days: 7
# Disabled for now - uncomment when ready to build iOS TV 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'))
# 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
# runs-on: macos-26 name: 🍎 Build tvOS IPA
# name: 🍎 Build iOS IPA (TV) permissions:
# permissions: contents: read
# contents: read
# steps:
# steps: - name: 📥 Checkout code
# - name: 📥 Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with:
# with: ref: ${{ github.event.pull_request.head.sha || github.sha }}
# ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0
# fetch-depth: 0 submodules: recursive
# submodules: recursive 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@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 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') }} restore-keys: |
# restore-keys: | ${{ runner.os }}-bun-cache
# ${{ runner.os }}-bun-cache
# - name: 📦 Install dependencies and reload submodules
# - name: 📦 Install dependencies and reload submodules run: |
# run: | bun install --frozen-lockfile
# bun install --frozen-lockfile bun run submodule-reload
# bun run submodule-reload
# - name: 🛠️ Generate project files
# - name: 🛠️ Generate project files 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@v1 with:
# with: xcode-version: "26.2"
# xcode-version: '26.0.1'
# - name: 🏗️ Setup EAS
# - name: 🏗️ Setup EAS uses: expo/expo-github-action@main
# uses: expo/expo-github-action@main with:
# with: eas-version: latest
# eas-version: latest token: ${{ secrets.EXPO_TOKEN }}
# token: ${{ secrets.EXPO_TOKEN }} eas-cache: true
# eas-cache: true
# - name: 🚀 Build iOS app
# - name: 🚀 Build iOS app env:
# env: EXPO_TV: 1
# EXPO_TV: 1 run: eas build -p ios --local --non-interactive
# run: eas build -p ios --local --non-interactive
# - name: 📅 Set date tag
# - name: 📅 Set date tag 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with:
# with: name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
# name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }} path: build-*.ipa
# path: build-*.ipa retention-days: 7
# 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

View File

@@ -48,7 +48,6 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { import {
getMpvAudioId, getMpvAudioId,
@@ -134,7 +133,7 @@ export default function page() {
const audioIndexFromUrl = audioIndexStr const audioIndexFromUrl = audioIndexStr
? Number.parseInt(audioIndexStr, 10) ? Number.parseInt(audioIndexStr, 10)
: undefined; : undefined;
const subtitleIndexFromUrl = subtitleIndexStr const subtitleIndex = subtitleIndexStr
? Number.parseInt(subtitleIndexStr, 10) ? Number.parseInt(subtitleIndexStr, 10)
: -1; : -1;
const bitrateValue = bitrateValueStr const bitrateValue = bitrateValueStr
@@ -161,24 +160,6 @@ export default function page() {
return undefined; return undefined;
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]); }, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
// Resolve subtitle index: use URL param if provided, otherwise use stored index for offline playback
const subtitleIndex = useMemo(() => {
if (subtitleIndexFromUrl !== undefined) {
return subtitleIndexFromUrl;
}
if (
offline &&
downloadedItem?.userData?.subtitleStreamIndex !== undefined
) {
return downloadedItem.userData.subtitleStreamIndex;
}
return -1;
}, [
subtitleIndexFromUrl,
offline,
downloadedItem?.userData?.subtitleStreamIndex,
]);
// Get the playback speed for this item based on settings // Get the playback speed for this item based on settings
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed( const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
item, item,
@@ -424,8 +405,8 @@ export default function page() {
return { return {
ItemId: item.Id, ItemId: item.Id,
AudioStreamIndex: audioIndex, AudioStreamIndex: audioIndex ? audioIndex : undefined,
SubtitleStreamIndex: subtitleIndex, SubtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
MediaSourceId: mediaSourceId, MediaSourceId: mediaSourceId,
PositionTicks: msToTicks(progress.get()), PositionTicks: msToTicks(progress.get()),
IsPaused: !isPlaying, IsPaused: !isPlaying,
@@ -523,31 +504,6 @@ export default function page() {
return ticksToSeconds(getInitialPlaybackTicks()); return ticksToSeconds(getInitialPlaybackTicks());
}, [getInitialPlaybackTicks]); }, [getInitialPlaybackTicks]);
/** Prepare metadata for iOS native media controls (Control Center, Lock Screen) */
const nowPlayingMetadata = useMemo(() => {
if (!item || !api) return undefined;
const artworkUri = getPrimaryImageUrl({
api,
item,
quality: 90,
width: 500,
});
return {
title: item.Name || "",
artist:
item.Type === "Episode"
? item.SeriesName || ""
: item.AlbumArtist || "",
albumTitle:
item.Type === "Episode" && item.SeasonName
? item.SeasonName
: undefined,
artworkUri: artworkUri || undefined,
};
}, [item, api]);
/** Build video source config for MPV */ /** Build video source config for MPV */
const videoSource = useMemo<MpvVideoSource | undefined>(() => { const videoSource = useMemo<MpvVideoSource | undefined>(() => {
if (!stream?.url) return undefined; if (!stream?.url) return undefined;
@@ -976,7 +932,6 @@ export default function page() {
ref={videoRef} ref={videoRef}
source={videoSource} source={videoSource}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
nowPlayingMetadata={nowPlayingMetadata}
onProgress={onProgress} onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged} onPlaybackStateChange={onPlaybackStateChanged}
onLoad={() => setIsVideoLoaded(true)} onLoad={() => setIsVideoLoaded(true)}

View File

@@ -25,7 +25,6 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -54,9 +53,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, itemWithSources }) => { ({ item, itemWithSources }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
const { getDownloadedItemById } = useDownload();
const downloadedItem =
isOffline && item.Id ? getDownloadedItemById(item.Id) : null;
const { settings } = useSettings(); const { settings } = useSettings();
const { orientation } = useOrientation(); const { orientation } = useOrientation();
const navigation = useNavigation(); const navigation = useNavigation();
@@ -95,29 +91,17 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
// Needs to automatically change the selected to the default values for default indexes. // Needs to automatically change the selected to the default values for default indexes.
useEffect(() => { useEffect(() => {
// When offline, use the indices stored in userData (the last-used tracks for this file)
// rather than the server's defaults, so MediaSourceButton reflects what will actually play.
const offlineUserData = downloadedItem?.userData;
setSelectedOptions(() => ({ setSelectedOptions(() => ({
bitrate: defaultBitrate, bitrate: defaultBitrate,
mediaSource: defaultMediaSource ?? undefined, mediaSource: defaultMediaSource ?? undefined,
subtitleIndex: subtitleIndex: defaultSubtitleIndex ?? -1,
offlineUserData && !offlineUserData.isTranscoded audioIndex: defaultAudioIndex,
? offlineUserData.subtitleStreamIndex
: (defaultSubtitleIndex ?? -1),
audioIndex:
offlineUserData && !offlineUserData.isTranscoded
? offlineUserData.audioStreamIndex
: defaultAudioIndex,
})); }));
}, [ }, [
defaultAudioIndex, defaultAudioIndex,
defaultBitrate, defaultBitrate,
defaultSubtitleIndex, defaultSubtitleIndex,
defaultMediaSource, defaultMediaSource,
downloadedItem?.userData?.audioStreamIndex,
downloadedItem?.userData?.subtitleStreamIndex,
]); ]);
useEffect(() => { useEffect(() => {
@@ -248,12 +232,14 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
colors={itemColors} colors={itemColors}
/> />
<View className='w-1' /> <View className='w-1' />
<MediaSourceButton {!isOffline && (
selectedOptions={selectedOptions} <MediaSourceButton
setSelectedOptions={setSelectedOptions} selectedOptions={selectedOptions}
item={itemWithSources} setSelectedOptions={setSelectedOptions}
colors={itemColors} item={itemWithSources}
/> colors={itemColors}
/>
)}
</View> </View>
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (

View File

@@ -7,8 +7,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import type { ThemeColors } from "@/hooks/useImageColorsReturn"; import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { useDownload } from "@/providers/DownloadProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { BITRATES } from "./BitRateSheet"; import { BITRATES } from "./BitRateSheet";
import type { SelectedOptions } from "./ItemContent"; import type { SelectedOptions } from "./ItemContent";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown"; import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
@@ -30,14 +28,6 @@ export const MediaSourceButton: React.FC<Props> = ({
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const isOffline = useOfflineMode();
const { getDownloadedItemById } = useDownload();
// For transcoded downloads there's only one burned-in track — nothing to pick
const isTranscodedDownload = useMemo(() => {
if (!isOffline || !item?.Id) return false;
return getDownloadedItemById(item.Id)?.userData?.isTranscoded === true;
}, [isOffline, item?.Id, getDownloadedItemById]);
const effectiveColors = colors || { const effectiveColors = colors || {
primary: "#7c3aed", primary: "#7c3aed",
@@ -82,36 +72,34 @@ export const MediaSourceButton: React.FC<Props> = ({
const optionGroups: OptionGroup[] = useMemo(() => { const optionGroups: OptionGroup[] = useMemo(() => {
const groups: OptionGroup[] = []; const groups: OptionGroup[] = [];
if (!isOffline) { // Bitrate group
// Bitrate group groups.push({
title: t("item_card.quality"),
options: BITRATES.map((bitrate) => ({
type: "radio" as const,
label: bitrate.key,
value: bitrate,
selected: bitrate.value === selectedOptions.bitrate?.value,
onPress: () =>
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
})),
});
// Media Source group (only if multiple sources)
if (item?.MediaSources && item.MediaSources.length > 1) {
groups.push({ groups.push({
title: t("item_card.quality"), title: t("item_card.video"),
options: BITRATES.map((bitrate) => ({ options: item.MediaSources.map((source) => ({
type: "radio" as const, type: "radio" as const,
label: bitrate.key, label: getMediaSourceDisplayName(source),
value: bitrate, value: source,
selected: bitrate.value === selectedOptions.bitrate?.value, selected: source.Id === selectedOptions.mediaSource?.Id,
onPress: () => onPress: () =>
setSelectedOptions((prev) => prev && { ...prev, bitrate }), setSelectedOptions(
(prev) => prev && { ...prev, mediaSource: source },
),
})), })),
}); });
// Media Source group (only if multiple sources)
if (item?.MediaSources && item.MediaSources.length > 1) {
groups.push({
title: t("item_card.video"),
options: item.MediaSources.map((source) => ({
type: "radio" as const,
label: getMediaSourceDisplayName(source),
value: source,
selected: source.Id === selectedOptions.mediaSource?.Id,
onPress: () =>
setSelectedOptions(
(prev) => prev && { ...prev, mediaSource: source },
),
})),
});
}
} }
// Audio track group // Audio track group
@@ -162,7 +150,6 @@ export const MediaSourceButton: React.FC<Props> = ({
return groups; return groups;
}, [ }, [
item, item,
isOffline,
selectedOptions, selectedOptions,
audioStreams, audioStreams,
subtitleStreams, subtitleStreams,
@@ -191,8 +178,6 @@ export const MediaSourceButton: React.FC<Props> = ({
</TouchableOpacity> </TouchableOpacity>
); );
if (isTranscodedDownload) return null;
return ( return (
<PlatformDropdown <PlatformDropdown
groups={optionGroups} groups={optionGroups}

View File

@@ -96,23 +96,14 @@ export const PlayButton: React.FC<Props> = ({
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item.Id!, itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "", mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0", playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false", offline: isOffline ? "true" : "false",
}); });
if (selectedOptions.audioIndex !== undefined) {
queryParams.set("audioIndex", selectedOptions.audioIndex.toString());
}
if (selectedOptions.subtitleIndex !== undefined) {
queryParams.set(
"subtitleIndex",
selectedOptions.subtitleIndex.toString(),
);
}
const queryString = queryParams.toString(); const queryString = queryParams.toString();
if (!client) { if (!client) {
@@ -301,29 +292,6 @@ export const PlayButton: React.FC<Props> = ({
t, t,
]); ]);
const buildOfflineQueryParams = useCallback(
(downloadedItem: NonNullable<ReturnType<typeof getDownloadedItemById>>) => {
const isTranscoded = downloadedItem.userData?.isTranscoded === true;
const audioIdx = isTranscoded
? downloadedItem.userData?.audioStreamIndex
: selectedOptions.audioIndex;
const subtitleIdx = isTranscoded
? downloadedItem.userData?.subtitleStreamIndex
: selectedOptions.subtitleIndex;
const params = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
if (audioIdx !== undefined) params.set("audioIndex", audioIdx.toString());
if (subtitleIdx !== undefined)
params.set("subtitleIndex", subtitleIdx.toString());
return params;
},
[item, selectedOptions],
);
const onPress = useCallback(async () => { const onPress = useCallback(async () => {
if (!item) return; if (!item) return;
@@ -334,7 +302,13 @@ export const PlayButton: React.FC<Props> = ({
// If already in offline mode, play downloaded file directly // If already in offline mode, play downloaded file directly
if (isOffline && downloadedItem) { if (isOffline && downloadedItem) {
goToPlayer(buildOfflineQueryParams(downloadedItem).toString()); const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
return; return;
} }
@@ -357,9 +331,13 @@ export const PlayButton: React.FC<Props> = ({
<Button <Button
onPress={() => { onPress={() => {
hideModal(); hideModal();
goToPlayer( const queryParams = new URLSearchParams({
buildOfflineQueryParams(downloadedItem).toString(), itemId: item.Id!,
); offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}} }}
color='purple' color='purple'
> >
@@ -396,7 +374,13 @@ export const PlayButton: React.FC<Props> = ({
{ {
text: t("player.downloaded_file_yes"), text: t("player.downloaded_file_yes"),
onPress: () => { onPress: () => {
goToPlayer(buildOfflineQueryParams(downloadedItem).toString()); const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}, },
isPreferred: true, isPreferred: true,
}, },

View File

@@ -1,7 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useCallback } from "react"; import { useCallback } from "react";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { getDownloadedItemById } from "@/providers/Downloads/database";
import { usePlaySettings } from "@/providers/PlaySettingsProvider"; import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
@@ -16,27 +15,12 @@ export const useDownloadedFileOpener = () => {
console.error("Attempted to open a file without an ID."); console.error("Attempted to open a file without an ID.");
return; return;
} }
const downloadedItem = getDownloadedItemById(item.Id);
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item.Id, itemId: item.Id,
offline: "true", offline: "true",
playbackPosition: playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0", item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
}); });
if (downloadedItem?.userData?.audioStreamIndex !== undefined) {
queryParams.set(
"audioIndex",
downloadedItem.userData.audioStreamIndex.toString(),
);
}
if (downloadedItem?.userData?.subtitleStreamIndex !== undefined) {
queryParams.set(
"subtitleIndex",
downloadedItem.userData.subtitleStreamIndex.toString(),
);
}
try { try {
router.push(`/player/direct-player?${queryParams.toString()}`); router.push(`/player/direct-player?${queryParams.toString()}`);
} catch (error) { } catch (error) {

View File

@@ -186,20 +186,6 @@ export const usePlaybackManager = ({
: playedPercentage, : playedPercentage,
}, },
}, },
// Sync selected audio/subtitle tracks so next playback resumes with
// the same tracks the user had active — but only for non-transcoded
// downloads where the user can freely switch tracks.
userData: localItem.userData.isTranscoded
? localItem.userData
: {
...localItem.userData,
audioStreamIndex:
playbackProgressInfo.AudioStreamIndex ??
localItem.userData.audioStreamIndex,
subtitleStreamIndex:
playbackProgressInfo.SubtitleStreamIndex ??
localItem.userData.subtitleStreamIndex,
},
}); });
// Force invalidate queries so they refetch from updated local database // Force invalidate queries so they refetch from updated local database
queryClient.invalidateQueries({ queryKey: ["item", itemId] }); queryClient.invalidateQueries({ queryKey: ["item", itemId] });

View File

@@ -43,12 +43,6 @@ class MpvPlayerModule : Module() {
view.loadVideo(config) view.loadVideo(config)
} }
// Now Playing metadata for media controls (iOS-only, no-op on Android)
// Android handles media session differently via MediaSessionCompat
Prop("nowPlayingMetadata") { _: MpvPlayerView, _: Map<String, String>? ->
// No-op on Android - media session integration would require MediaSessionCompat
}
// Async function to play video // Async function to play video
AsyncFunction("play") { view: MpvPlayerView -> AsyncFunction("play") { view: MpvPlayerView ->
view.play() view.play()

View File

@@ -10,7 +10,6 @@ protocol MPVLayerRendererDelegate: AnyObject {
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool) func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool) func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool) func renderer(_ renderer: MPVLayerRenderer, didBecomeTracksReady: Bool)
func renderer(_ renderer: MPVLayerRenderer, didSelectAudioOutput audioOutput: String)
} }
/// MPV player using vo_avfoundation for video output. /// MPV player using vo_avfoundation for video output.
@@ -348,8 +347,7 @@ final class MPVLayerRenderer {
("pause", MPV_FORMAT_FLAG), ("pause", MPV_FORMAT_FLAG),
("track-list/count", MPV_FORMAT_INT64), ("track-list/count", MPV_FORMAT_INT64),
("paused-for-cache", MPV_FORMAT_FLAG), ("paused-for-cache", MPV_FORMAT_FLAG),
("demuxer-cache-duration", MPV_FORMAT_DOUBLE), ("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
("current-ao", MPV_FORMAT_STRING)
] ]
for (name, format) in properties { for (name, format) in properties {
mpv_observe_property(handle, 0, name, format) mpv_observe_property(handle, 0, name, format)
@@ -554,15 +552,6 @@ final class MPVLayerRenderer {
self.delegate?.renderer(self, didBecomeTracksReady: true) self.delegate?.renderer(self, didBecomeTracksReady: true)
} }
} }
case "current-ao":
// Audio output is now active - notify delegate
if let aoName = getStringProperty(handle: handle, name: name) {
print("[MPV] 🔊 Audio output selected: \(aoName)")
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.delegate?.renderer(self, didSelectAudioOutput: aoName)
}
}
default: default:
break break
} }

View File

@@ -1,188 +0,0 @@
import Foundation
import MediaPlayer
import UIKit
import AVFoundation
/// Simple manager for Now Playing info and remote commands.
/// Stores all state internally and updates Now Playing when ready.
class MPVNowPlayingManager {
static let shared = MPVNowPlayingManager()
// State
private var title: String?
private var artist: String?
private var albumTitle: String?
private var cachedArtwork: MPMediaItemArtwork?
private var duration: TimeInterval = 0
private var position: TimeInterval = 0
private var isPlaying: Bool = false
private var isCommandsSetup = false
private var artworkTask: URLSessionDataTask?
private init() {}
// MARK: - Audio Session
func activateAudioSession() {
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .moviePlayback)
try session.setActive(true)
print("[NowPlaying] Audio session activated")
} catch {
print("[NowPlaying] Audio session error: \(error)")
}
}
func deactivateAudioSession() {
do {
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
print("[NowPlaying] Audio session deactivated")
} catch {
print("[NowPlaying] Deactivation error: \(error)")
}
}
// MARK: - Remote Commands
func setupRemoteCommands(
playHandler: @escaping () -> Void,
pauseHandler: @escaping () -> Void,
toggleHandler: @escaping () -> Void,
seekHandler: @escaping (TimeInterval) -> Void,
skipForward: @escaping (TimeInterval) -> Void,
skipBackward: @escaping (TimeInterval) -> Void
) {
guard !isCommandsSetup else { return }
isCommandsSetup = true
DispatchQueue.main.async {
UIApplication.shared.beginReceivingRemoteControlEvents()
}
let cc = MPRemoteCommandCenter.shared()
cc.playCommand.isEnabled = true
cc.playCommand.addTarget { _ in playHandler(); return .success }
cc.pauseCommand.isEnabled = true
cc.pauseCommand.addTarget { _ in pauseHandler(); return .success }
cc.togglePlayPauseCommand.isEnabled = true
cc.togglePlayPauseCommand.addTarget { _ in toggleHandler(); return .success }
cc.skipForwardCommand.isEnabled = true
cc.skipForwardCommand.preferredIntervals = [15]
cc.skipForwardCommand.addTarget { e in
if let ev = e as? MPSkipIntervalCommandEvent { skipForward(ev.interval) }
return .success
}
cc.skipBackwardCommand.isEnabled = true
cc.skipBackwardCommand.preferredIntervals = [15]
cc.skipBackwardCommand.addTarget { e in
if let ev = e as? MPSkipIntervalCommandEvent { skipBackward(ev.interval) }
return .success
}
cc.changePlaybackPositionCommand.isEnabled = true
cc.changePlaybackPositionCommand.addTarget { e in
if let ev = e as? MPChangePlaybackPositionCommandEvent { seekHandler(ev.positionTime) }
return .success
}
print("[NowPlaying] Remote commands ready")
}
func cleanupRemoteCommands() {
guard isCommandsSetup else { return }
let cc = MPRemoteCommandCenter.shared()
cc.playCommand.removeTarget(nil)
cc.pauseCommand.removeTarget(nil)
cc.togglePlayPauseCommand.removeTarget(nil)
cc.skipForwardCommand.removeTarget(nil)
cc.skipBackwardCommand.removeTarget(nil)
cc.changePlaybackPositionCommand.removeTarget(nil)
DispatchQueue.main.async {
UIApplication.shared.endReceivingRemoteControlEvents()
}
isCommandsSetup = false
print("[NowPlaying] Remote commands cleaned up")
}
// MARK: - State Updates (call these whenever data changes)
/// Set metadata (title, artist, artwork URL)
func setMetadata(title: String?, artist: String?, albumTitle: String?, artworkUrl: String?) {
self.title = title
self.artist = artist
self.albumTitle = albumTitle
print("[NowPlaying] Metadata: \(title ?? "nil")")
// Load artwork async
artworkTask?.cancel()
if let urlString = artworkUrl, let url = URL(string: urlString) {
artworkTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
if let data = data, let image = UIImage(data: data) {
self?.cachedArtwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
print("[NowPlaying] Artwork loaded")
DispatchQueue.main.async { self?.refresh() }
}
}
artworkTask?.resume()
}
refresh()
}
/// Update playback state (position, duration, playing)
func updatePlayback(position: TimeInterval, duration: TimeInterval, isPlaying: Bool) {
self.position = position
self.duration = duration
self.isPlaying = isPlaying
refresh()
}
/// Clear everything
func clear() {
artworkTask?.cancel()
title = nil
artist = nil
albumTitle = nil
cachedArtwork = nil
duration = 0
position = 0
isPlaying = false
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
print("[NowPlaying] Cleared")
}
// MARK: - Private
/// Refresh Now Playing info if we have enough data
private func refresh() {
guard duration > 0 else {
print("[NowPlaying] refresh skipped - duration is 0")
return
}
var info: [String: Any] = [
MPMediaItemPropertyPlaybackDuration: duration,
MPNowPlayingInfoPropertyElapsedPlaybackTime: position,
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0
]
if let title { info[MPMediaItemPropertyTitle] = title }
if let artist { info[MPMediaItemPropertyArtist] = artist }
if let albumTitle { info[MPMediaItemPropertyAlbumTitle] = albumTitle }
if let cachedArtwork { info[MPMediaItemPropertyArtwork] = cachedArtwork }
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
print("[NowPlaying] ✅ Set info: title=\(title ?? "nil"), dur=\(Int(duration))s, pos=\(Int(position))s, rate=\(isPlaying ? 1.0 : 0.0)")
}
}

View File

@@ -43,21 +43,6 @@ public class MpvPlayerModule: Module {
view.loadVideo(config: config) view.loadVideo(config: config)
} }
// Now Playing metadata for iOS Control Center and Lock Screen
Prop("nowPlayingMetadata") { (view: MpvPlayerView, metadata: [String: Any]?) in
guard let metadata = metadata else { return }
// Convert Any values to String, filtering out nil/null values
var stringMetadata: [String: String] = [:]
for (key, value) in metadata {
if let stringValue = value as? String {
stringMetadata[key] = stringValue
}
}
if !stringMetadata.isEmpty {
view.setNowPlayingMetadata(stringMetadata)
}
}
// Async function to play video // Async function to play video
AsyncFunction("play") { (view: MpvPlayerView) in AsyncFunction("play") { (view: MpvPlayerView) in
view.play() view.play()

View File

@@ -1,7 +1,6 @@
import AVFoundation import AVFoundation
import CoreMedia import CoreMedia
import ExpoModulesCore import ExpoModulesCore
import MediaPlayer
import UIKit import UIKit
/// Configuration for loading a video /// Configuration for loading a video
@@ -42,6 +41,7 @@ class MpvPlayerView: ExpoView {
private var renderer: MPVLayerRenderer? private var renderer: MPVLayerRenderer?
private var videoContainer: UIView! private var videoContainer: UIView!
private var pipController: PiPController? private var pipController: PiPController?
let onLoad = EventDispatcher() let onLoad = EventDispatcher()
let onPlaybackStateChange = EventDispatcher() let onPlaybackStateChange = EventDispatcher()
let onProgress = EventDispatcher() let onProgress = EventDispatcher()
@@ -53,14 +53,11 @@ class MpvPlayerView: ExpoView {
private var cachedDuration: Double = 0 private var cachedDuration: Double = 0
private var intendedPlayState: Bool = false private var intendedPlayState: Bool = false
private var _isZoomedToFill: Bool = false private var _isZoomedToFill: Bool = false
// Reference to now playing manager
private let nowPlayingManager = MPVNowPlayingManager.shared
required init(appContext: AppContext? = nil) { required init(appContext: AppContext? = nil) {
super.init(appContext: appContext) super.init(appContext: appContext)
setupNotifications()
setupView() setupView()
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
} }
private func setupView() { private func setupView() {
@@ -112,77 +109,6 @@ class MpvPlayerView: ExpoView {
CATransaction.commit() CATransaction.commit()
} }
// MARK: - Audio Session & Notifications
private func setupNotifications() {
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
NotificationCenter.default.addObserver(
self, selector: #selector(handleAudioSessionInterruption),
name: AVAudioSession.interruptionNotification, object: nil)
}
@objc func handleAudioSessionInterruption(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
return
}
switch type {
case .began:
// Interruption began - pause the video
print("[MPV] Audio session interrupted - pausing video")
self.pause()
case .ended:
// Interruption ended - check if we should resume
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
print("[MPV] Audio session interruption ended - can resume")
// Don't auto-resume - let user manually resume playback
} else {
print("[MPV] Audio session interruption ended - should not resume")
}
}
@unknown default:
break
}
}
private func setupRemoteCommands() {
nowPlayingManager.setupRemoteCommands(
playHandler: { [weak self] in self?.play() },
pauseHandler: { [weak self] in self?.pause() },
toggleHandler: { [weak self] in
guard let self else { return }
if self.intendedPlayState { self.pause() } else { self.play() }
},
seekHandler: { [weak self] time in self?.seekTo(position: time) },
skipForward: { [weak self] interval in self?.seekBy(offset: interval) },
skipBackward: { [weak self] interval in self?.seekBy(offset: -interval) }
)
}
// MARK: - Now Playing Info
func setNowPlayingMetadata(_ metadata: [String: String]) {
print("[MPV] setNowPlayingMetadata: \(metadata["title"] ?? "nil")")
nowPlayingManager.setMetadata(
title: metadata["title"],
artist: metadata["artist"],
albumTitle: metadata["albumTitle"],
artworkUrl: metadata["artworkUri"]
)
}
private func clearNowPlayingInfo() {
nowPlayingManager.cleanupRemoteCommands()
nowPlayingManager.deactivateAudioSession()
nowPlayingManager.clear()
}
func loadVideo(config: VideoLoadConfig) { func loadVideo(config: VideoLoadConfig) {
// Skip reload if same URL is already playing // Skip reload if same URL is already playing
if currentURL == config.url { if currentURL == config.url {
@@ -223,7 +149,6 @@ class MpvPlayerView: ExpoView {
func play() { func play() {
intendedPlayState = true intendedPlayState = true
setupRemoteCommands()
renderer?.play() renderer?.play()
pipController?.setPlaybackRate(1.0) pipController?.setPlaybackRate(1.0)
pipController?.updatePlaybackState() pipController?.updatePlaybackState()
@@ -237,17 +162,10 @@ class MpvPlayerView: ExpoView {
} }
func seekTo(position: Double) { func seekTo(position: Double) {
// Update cached position and Now Playing immediately for smooth Control Center feedback
cachedPosition = position
syncNowPlaying(isPlaying: !isPaused())
renderer?.seek(to: position) renderer?.seek(to: position)
} }
func seekBy(offset: Double) { func seekBy(offset: Double) {
// Update cached position and Now Playing immediately for smooth Control Center feedback
let newPosition = max(0, min(cachedPosition + offset, cachedDuration))
cachedPosition = newPosition
syncNowPlaying(isPlaying: !isPaused())
renderer?.seek(by: offset) renderer?.seek(by: offset)
} }
@@ -374,32 +292,23 @@ class MpvPlayerView: ExpoView {
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
renderer?.stop() renderer?.stop()
displayLayer.removeFromSuperlayer() displayLayer.removeFromSuperlayer()
clearNowPlayingInfo()
NotificationCenter.default.removeObserver(self)
} }
} }
// MARK: - MPVLayerRendererDelegate // MARK: - MPVLayerRendererDelegate
extension MpvPlayerView: MPVLayerRendererDelegate { extension MpvPlayerView: MPVLayerRendererDelegate {
// MARK: - Single location for Now Playing updates
private func syncNowPlaying(isPlaying: Bool) {
print("[MPV] syncNowPlaying: pos=\(Int(cachedPosition))s, dur=\(Int(cachedDuration))s, playing=\(isPlaying)")
nowPlayingManager.updatePlayback(position: cachedPosition, duration: cachedDuration, isPlaying: isPlaying)
}
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) { func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) {
cachedPosition = position cachedPosition = position
cachedDuration = duration cachedDuration = duration
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
// Update PiP current time for progress bar
if self.pipController?.isPictureInPictureActive == true { if self.pipController?.isPictureInPictureActive == true {
self.pipController?.setCurrentTimeFromSeconds(position, duration: duration) self.pipController?.setCurrentTimeFromSeconds(position, duration: duration)
} }
self.onProgress([ self.onProgress([
"position": position, "position": position,
"duration": duration, "duration": duration,
@@ -412,10 +321,12 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) { func renderer(_: MPVLayerRenderer, didChangePause isPaused: Bool) {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
// Don't update intendedPlayState here - it's only set by user actions (play/pause)
// This prevents PiP UI flicker during seeking
print("[MPV] didChangePause: isPaused=\(isPaused), cachedDuration=\(self.cachedDuration)") // Sync timebase rate with actual playback state
self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0) self.pipController?.setPlaybackRate(isPaused ? 0.0 : 1.0)
self.syncNowPlaying(isPlaying: !isPaused)
self.onPlaybackStateChange([ self.onPlaybackStateChange([
"isPaused": isPaused, "isPaused": isPaused,
"isPlaying": !isPaused, "isPlaying": !isPaused,
@@ -447,13 +358,6 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
self.onTracksReady([:]) self.onTracksReady([:])
} }
} }
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
// Audio output is now active - this is the right time to activate audio session and set Now Playing
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
nowPlayingManager.activateAudioSession()
syncNowPlaying(isPlaying: !isPaused())
}
} }
// MARK: - PiPControllerDelegate // MARK: - PiPControllerDelegate

View File

@@ -25,13 +25,6 @@ export type OnErrorEventPayload = {
export type OnTracksReadyEventPayload = Record<string, never>; export type OnTracksReadyEventPayload = Record<string, never>;
export type NowPlayingMetadata = {
title?: string;
artist?: string;
albumTitle?: string;
artworkUri?: string;
};
export type MpvPlayerModuleEvents = { export type MpvPlayerModuleEvents = {
onChange: (params: ChangeEventPayload) => void; onChange: (params: ChangeEventPayload) => void;
}; };
@@ -55,8 +48,6 @@ export type VideoSource = {
export type MpvPlayerViewProps = { export type MpvPlayerViewProps = {
source?: VideoSource; source?: VideoSource;
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
/** Metadata for iOS Control Center and Lock Screen now playing info */
nowPlayingMetadata?: NowPlayingMetadata;
onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void; onLoad?: (event: { nativeEvent: OnLoadEventPayload }) => void;
onPlaybackStateChange?: (event: { onPlaybackStateChange?: (event: {
nativeEvent: OnPlaybackStateChangePayload; nativeEvent: OnPlaybackStateChangePayload;

View File

@@ -15,6 +15,7 @@
"android:tv": "cross-env EXPO_TV=1 expo run:android", "android:tv": "cross-env EXPO_TV=1 expo run:android",
"build:android:local": "cd android && cross-env NODE_ENV=production ./gradlew assembleRelease", "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": "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", "prepare": "husky",
"typecheck": "node scripts/typecheck.js", "typecheck": "node scripts/typecheck.js",
"check": "biome check . --max-diagnostics 1000", "check": "biome check . --max-diagnostics 1000",