mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-20 02:52:30 +00:00
Compare commits
1 Commits
sync-subti
...
fix/build
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a44fefbccb |
39
.github/workflows/artifact-comment.yml
vendored
39
.github/workflows/artifact-comment.yml
vendored
@@ -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`;
|
||||||
|
|||||||
184
.github/workflows/build-apps.yml
vendored
184
.github/workflows/build-apps.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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' />
|
||||||
|
{!isOffline && (
|
||||||
<MediaSourceButton
|
<MediaSourceButton
|
||||||
selectedOptions={selectedOptions}
|
selectedOptions={selectedOptions}
|
||||||
setSelectedOptions={setSelectedOptions}
|
setSelectedOptions={setSelectedOptions}
|
||||||
item={itemWithSources}
|
item={itemWithSources}
|
||||||
colors={itemColors}
|
colors={itemColors}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
|
|||||||
@@ -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,7 +72,6 @@ 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({
|
groups.push({
|
||||||
title: t("item_card.quality"),
|
title: t("item_card.quality"),
|
||||||
@@ -112,7 +101,6 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Audio track group
|
// Audio track group
|
||||||
if (audioStreams.length > 0) {
|
if (audioStreams.length > 0) {
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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] });
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -54,13 +54,10 @@ class MpvPlayerView: ExpoView {
|
|||||||
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,28 +292,19 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user