Compare commits

..

1 Commits

Author SHA1 Message Date
Gauvain
61652ad322 fix(player): correct prev/next episode buttons at boundaries & for missing episodes
Previous/next were derived from the adjacentTo response's array length,
which wrongly surfaced the current episode as "previous" on the first
episode (e.g. S1E1) and offered "Virtual" (missing) episodes — like an
empty Specials entry — as navigation/autoplay targets.

Derive them from the current item's actual index in the list and skip
neighbours that are the current item or have LocationType 'Virtual'. This
also stops autoplay from advancing into a missing episode.
2026-06-02 00:26:23 +02:00
3 changed files with 82 additions and 43 deletions

View File

@@ -1,7 +1,13 @@
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import React, { useEffect, useState } from "react";
import {
type LayoutChangeEvent,
Platform,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
@@ -211,6 +217,24 @@ const PlatformDropdownComponent = ({
}: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal();
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
// `matchContents` doesn't help here: it reports the native Menu's size via
// setStyleSize and overrides any explicit size. Instead we measure the
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
const [triggerSize, setTriggerSize] = useState<{
width: number;
height: number;
} | null>(null);
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setTriggerSize((prev) =>
prev && prev.width === width && prev.height === height
? prev
: { width, height },
);
};
// Handle controlled open state for Android
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) {
@@ -241,11 +265,25 @@ const PlatformDropdownComponent = ({
}, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios" && !Platform.isTV) {
// @expo/ui's <Host> can't size to content, so an in-flow invisible copy of
// the trigger sizes the wrapper while the Host overlays the real Menu.
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
// fills its parent and reports its own size via setStyleSize, so it can't
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
// height depends on the parent while the parent depends on the Host — a
// circular dependency that collapses to 0 for any selector nested more than
// one level deep (so only the first, shallowest dropdown stays visible).
// Giving the wrapper the measured size breaks the cycle; the Host then
// fills a concrete box.
return (
<View>
<View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
<View style={triggerSize ?? { opacity: 0 }}>
{/* Hidden measurer: lays the trigger out off-flow to capture its
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
sizes to the trigger's content rather than to its parent. */}
<View
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
pointerEvents='none'
aria-hidden
onLayout={handleMeasureTrigger}
>
{trigger}
</View>
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>

View File

@@ -109,30 +109,35 @@ export const usePlaybackManager = ({
staleTime: 0,
});
/**
* Derive prev/next from the current item's real position in the adjacent
* list rather than from the array length. `getEpisodes({ adjacentTo })` does
* not guarantee a fixed [prev, current, next] shape — at the first/last
* episode it can still return the current item as the first/last entry — so
* length-based indexing wrongly surfaces the current episode as "previous".
*/
const currentIndex = useMemo(
() => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1,
[adjacentItems, item],
);
/** A neighbour is only navigable if it has an actual media file (not a
* "Virtual"/missing episode placeholder, e.g. an absent Special). */
const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto =>
!!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual";
const previousItem = useMemo(() => {
if (!adjacentItems || adjacentItems.length <= 1) {
return null;
}
if (adjacentItems.length === 2) {
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
}
return adjacentItems[0];
}, [adjacentItems, item]);
if (!adjacentItems || currentIndex <= 0) return null;
const candidate = adjacentItems[currentIndex - 1];
return isNavigable(candidate) ? candidate : null;
}, [adjacentItems, currentIndex, item]);
/** The next item in the series */
const nextItem = useMemo(() => {
if (!adjacentItems || adjacentItems.length <= 1) {
return null;
}
if (adjacentItems.length === 2) {
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
}
return adjacentItems[2];
}, [adjacentItems, item]);
if (!adjacentItems || currentIndex < 0) return null;
const candidate = adjacentItems[currentIndex + 1];
return isNavigable(candidate) ? candidate : null;
}, [adjacentItems, currentIndex, item]);
/**
* Reports playback progress.

View File

@@ -81,6 +81,7 @@ class MpvPlayerView: ExpoView {
private func setupView() {
clipsToBounds = true
backgroundColor = .black
configureAudioSession()
videoContainer = UIView()
videoContainer.translatesAutoresizingMaskIntoConstraints = false
@@ -140,26 +141,21 @@ class MpvPlayerView: ExpoView {
CATransaction.commit()
}
// MARK: - Audio Session & Notifications
private func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
let audioSession = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
try session.setActive(true)
try audioSession.setCategory(
.playback,
mode: .moviePlayback,
policy: .longFormAudio,
options: []
)
try audioSession.setActive(true)
} catch {
print("Failed to configure audio session: \(error)")
}
}
/// Deactivate the session AND reset the category `setActive(false)` alone
/// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later
/// reactivation (foreground, route change, other modules) re-steals audio.
private func tearDownAudioSession() {
let session = AVAudioSession.sharedInstance()
try? session.setActive(false, options: .notifyOthersOnDeactivation)
try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
}
// MARK: - Audio Session & Notifications
private func setupNotifications() {
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
@@ -274,7 +270,6 @@ class MpvPlayerView: ExpoView {
func play() {
intendedPlayState = true
configureAudioSession()
setupRemoteCommands()
renderer?.play()
pipController?.setPlaybackRate(1.0)
@@ -445,7 +440,6 @@ class MpvPlayerView: ExpoView {
renderer?.stop()
displayLayer.removeFromSuperlayer()
clearNowPlayingInfo()
tearDownAudioSession()
NotificationCenter.default.removeObserver(self)
}
}
@@ -525,7 +519,9 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
}
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing")
// 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())
}
}