Compare commits

..

8 Commits

Author SHA1 Message Date
lance chant
2e4384568f Merge branch 'develop' into fix/skip-button-rework 2026-07-20 08:23:11 +02:00
lance chant
f9e5dc35da Merge branch 'develop' into fix/skip-button-rework 2026-07-19 11:35:02 +02:00
Lance Chant
5de017612d Merge remote-tracking branch 'origin/develop' into fix/skip-button-rework
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-19 11:05:29 +02:00
Lance Chant
ca9e9f1486 removing comment about the skip button location
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-19 11:02:56 +02:00
lance chant
980245f19d Merge branch 'develop' into fix/skip-button-rework 2026-07-17 14:47:48 +02:00
lance chant
228e847a4e Update components/video-player/controls/SkipSegmentOverlay.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-17 11:51:37 +02:00
Lance Chant
15c66bc714 Addressing PR comments
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-17 11:45:42 +02:00
Lance Chant
c4209cbf8e fix: fixing the skip button
Allowing it to show independently of the controls
Fixing the position of the button to fit over things better

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-17 11:02:23 +02:00
13 changed files with 173 additions and 111 deletions

View File

@@ -61,14 +61,14 @@ jobs:
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build # ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure # (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
# fail). Pin Temurin 17 for a deterministic Android build. # fail). Pin Temurin 17 for a deterministic Android build.
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "17"
- parallel: - parallel:
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -76,7 +76,7 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-bun- ${{ runner.os }}-${{ runner.arch }}-bun-
- name: 💾 Cache Gradle global - name: 💾 Cache Gradle global
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with: with:
path: | path: |
~/.gradle/caches/modules-2 ~/.gradle/caches/modules-2
@@ -94,7 +94,7 @@ jobs:
run: bun run prebuild run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle) - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with: with:
path: android/.gradle path: android/.gradle
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -163,14 +163,14 @@ jobs:
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build # ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure # (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
# fail). Pin Temurin 17 for a deterministic Android build. # fail). Pin Temurin 17 for a deterministic Android build.
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "17"
- parallel: - parallel:
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -178,7 +178,7 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-bun- ${{ runner.os }}-${{ runner.arch }}-bun-
- name: 💾 Cache Gradle global - name: 💾 Cache Gradle global
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with: with:
path: | path: |
~/.gradle/caches/modules-2 ~/.gradle/caches/modules-2
@@ -196,7 +196,7 @@ jobs:
run: bun run prebuild:tv run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle) - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with: with:
path: android/.gradle path: android/.gradle
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -262,7 +262,7 @@ jobs:
eas-cache: true eas-cache: true
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -328,7 +328,7 @@ jobs:
xcode-version: "26.6" xcode-version: "26.6"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -403,7 +403,7 @@ jobs:
eas-cache: true eas-cache: true
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -467,7 +467,7 @@ jobs:
xcode-version: "26.6" xcode-version: "26.6"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}

View File

@@ -33,7 +33,7 @@ jobs:
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with: with:
path: | path: |
~/.bun/install/cache ~/.bun/install/cache

View File

@@ -30,13 +30,13 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: 🏁 Initialize CodeQL - name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild - name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
- name: 🧪 Perform CodeQL Analysis - name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2

View File

@@ -28,7 +28,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin - name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@e0c8f73cdc0fafde9396e056c5038217000a32d1 # v2.16.4 uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
with: with:
upload_sources: true upload_sources: true
upload_translations: true upload_translations: true

View File

@@ -53,7 +53,7 @@ jobs:
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -97,7 +97,7 @@ jobs:
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}

View File

@@ -59,7 +59,7 @@ jobs:
- name: 💬 Post title error - name: 💬 Post title error
if: steps.result.outputs.has_error == 'true' if: steps.result.outputs.has_error == 'true'
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with: with:
number: ${{ steps.result.outputs.number }} number: ${{ steps.result.outputs.number }}
header: pr-title-lint-error header: pr-title-lint-error
@@ -74,7 +74,7 @@ jobs:
- name: 🧹 Clear title error - name: 🧹 Clear title error
if: steps.result.outputs.has_error == 'false' if: steps.result.outputs.has_error == 'false'
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with: with:
number: ${{ steps.result.outputs.number }} number: ${{ steps.result.outputs.number }}
header: pr-title-lint-error header: pr-title-lint-error

View File

@@ -87,7 +87,7 @@ jobs:
eas-cache: true eas-cache: true
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}

View File

@@ -44,7 +44,7 @@ jobs:
output: trivy-results.sarif output: trivy-results.sarif
- name: 📤 Upload results to code scanning - name: 📤 Upload results to code scanning
uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with: with:
sarif_file: trivy-results.sarif sarif_file: trivy-results.sarif
category: trivy-fs category: trivy-fs

View File

@@ -1040,11 +1040,7 @@ export default function DirectPlayerPage() {
// e.g. to burn an image sub in or out). Same-item mirror of VideoContext's // e.g. to burn an image sub in or out). Same-item mirror of VideoContext's
// replacePlayer, resuming at the live position. // replacePlayer, resuming at the live position.
const replaceWithTrackSelection = useCallback( const replaceWithTrackSelection = useCallback(
(params: { (params: { subtitleIndex?: string; audioIndex?: string }) => {
subtitleIndex?: string;
audioIndex?: string;
bitrateValue?: string;
}) => {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item?.Id ?? "", itemId: item?.Id ?? "",
audioIndex: params.audioIndex ?? String(currentAudioIndex ?? ""), audioIndex: params.audioIndex ?? String(currentAudioIndex ?? ""),
@@ -1052,7 +1048,7 @@ export default function DirectPlayerPage() {
params.subtitleIndex ?? params.subtitleIndex ??
String(toServerSubtitleIndex(currentSubtitleIndex)), String(toServerSubtitleIndex(currentSubtitleIndex)),
mediaSourceId: stream?.mediaSource?.Id ?? "", mediaSourceId: stream?.mediaSource?.Id ?? "",
bitrateValue: params.bitrateValue ?? bitrateValue?.toString() ?? "", bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition: msToTicks(progress.get()).toString(), playbackPosition: msToTicks(progress.get()).toString(),
}).toString(); }).toString();
// Destroy the current mpv instance before re-navigating, same rationale as // Destroy the current mpv instance before re-navigating, same rationale as
@@ -1072,16 +1068,6 @@ export default function DirectPlayerPage() {
], ],
); );
// TV quality (max bitrate) change handler. A bitrate change always requires
// re-negotiating the stream with the server, so it goes through the same
// player-replace path as track changes while transcoding.
const handleBitrateChange = useCallback(
(bitrate: number | undefined) => {
replaceWithTrackSelection({ bitrateValue: bitrate?.toString() ?? "" });
},
[replaceWithTrackSelection],
);
// TV/mobile subtitle track change handler // TV/mobile subtitle track change handler
const handleSubtitleIndexChange = useCallback( const handleSubtitleIndexChange = useCallback(
async (index: number) => { async (index: number) => {
@@ -1553,7 +1539,6 @@ export default function DirectPlayerPage() {
subtitleIndex={currentSubtitleIndex} subtitleIndex={currentSubtitleIndex}
onAudioIndexChange={handleAudioIndexChange} onAudioIndexChange={handleAudioIndexChange}
onSubtitleIndexChange={handleSubtitleIndexChange} onSubtitleIndexChange={handleSubtitleIndexChange}
onBitrateChange={handleBitrateChange}
previousItem={previousItem} previousItem={previousItem}
nextItem={nextItem} nextItem={nextItem}
goToPreviousItem={goToPreviousItem} goToPreviousItem={goToPreviousItem}

View File

@@ -15,7 +15,6 @@ import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters"; import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
import { TimeDisplay } from "./TimeDisplay"; import { TimeDisplay } from "./TimeDisplay";
import { TrickplayBubble } from "./TrickplayBubble"; import { TrickplayBubble } from "./TrickplayBubble";
@@ -34,11 +33,8 @@ interface BottomControlsProps {
showRemoteBubble: boolean; showRemoteBubble: boolean;
currentTime: number; currentTime: number;
remainingTime: number; remainingTime: number;
showSkipButton: boolean;
showSkipCreditButton: boolean; showSkipCreditButton: boolean;
hasContentAfterCredits: boolean; hasContentAfterCredits: boolean;
skipIntro: () => void;
skipCredit: () => void;
nextItem?: BaseItemDto | null; nextItem?: BaseItemDto | null;
handleNextEpisodeAutoPlay: () => void; handleNextEpisodeAutoPlay: () => void;
handleNextEpisodeManual: () => void; handleNextEpisodeManual: () => void;
@@ -86,11 +82,8 @@ export const BottomControls: FC<BottomControlsProps> = ({
showRemoteBubble, showRemoteBubble,
currentTime, currentTime,
remainingTime, remainingTime,
showSkipButton,
showSkipCreditButton, showSkipCreditButton,
hasContentAfterCredits, hasContentAfterCredits,
skipIntro,
skipCredit,
nextItem, nextItem,
handleNextEpisodeAutoPlay, handleNextEpisodeAutoPlay,
handleNextEpisodeManual, handleNextEpisodeManual,
@@ -180,21 +173,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
) : null} ) : null}
</View> </View>
<View className='flex flex-row items-center space-x-2 shrink-0'> <View className='flex flex-row items-center space-x-2 shrink-0'>
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
buttonText={t("player.skip_intro")}
/>
{/* Smart Skip Credits behavior:
- Show "Skip Credits" if there's content after credits OR no next episode
- Show "Next Episode" if credits extend to video end AND next episode exists */}
<SkipButton
showButton={
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
}
onPress={skipCredit}
buttonText={t("player.skip_credits")}
/>
{settings.autoPlayNextEpisode !== false && {settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 || (settings.maxAutoPlayEpisodeCount.value === -1 ||
settings.autoPlayEpisodeCount < settings.autoPlayEpisodeCount <

View File

@@ -39,6 +39,7 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoNavigation } from "./hooks/useVideoNavigation"; import { useVideoNavigation } from "./hooks/useVideoNavigation";
import { useVideoSlider } from "./hooks/useVideoSlider"; import { useVideoSlider } from "./hooks/useVideoSlider";
import { useVideoTime } from "./hooks/useVideoTime"; import { useVideoTime } from "./hooks/useVideoTime";
import { SkipSegmentOverlay } from "./SkipSegmentOverlay";
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay"; import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
import { useControlsTimeout } from "./useControlsTimeout"; import { useControlsTimeout } from "./useControlsTimeout";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
@@ -355,6 +356,16 @@ export const Controls: FC<Props> = ({
maxMs, maxMs,
); );
// Whether the "Next Episode" countdown will actually be rendered. The Skip
// Credits button yields to it only when this is true; if autoplay is
// disabled or its episode limit is reached, Skip Credits must stay available
// (mirrors the NextEpisodeCountDownButton mount gate in BottomControls).
const willShowNextEpisode =
!!nextItem &&
settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 ||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value);
const goToItemCommon = useCallback( const goToItemCommon = useCallback(
(item: BaseItemDto) => { (item: BaseItemDto) => {
if (!item || !settings) { if (!item || !settings) {
@@ -587,11 +598,8 @@ export const Controls: FC<Props> = ({
showRemoteBubble={showRemoteBubble} showRemoteBubble={showRemoteBubble}
currentTime={currentTime} currentTime={currentTime}
remainingTime={remainingTime} remainingTime={remainingTime}
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton} showSkipCreditButton={showSkipCreditButton}
hasContentAfterCredits={hasContentAfterCredits} hasContentAfterCredits={hasContentAfterCredits}
skipIntro={skipIntro}
skipCredit={skipCredit}
nextItem={nextItem} nextItem={nextItem}
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay} handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
handleNextEpisodeManual={handleNextEpisodeManual} handleNextEpisodeManual={handleNextEpisodeManual}
@@ -611,6 +619,17 @@ export const Controls: FC<Props> = ({
time={isSliding || showRemoteBubble ? time : remoteTime} time={isSliding || showRemoteBubble ? time : remoteTime}
/> />
</Animated.View> </Animated.View>
{/* Skip Intro / Skip Credits float independently of the controls so
they're visible (and tappable) without summoning the controls. */}
<SkipSegmentOverlay
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton}
hasContentAfterCredits={hasContentAfterCredits}
willShowNextEpisode={willShowNextEpisode}
skipIntro={skipIntro}
skipCredit={skipCredit}
controlsVisible={showControls}
/>
</> </>
)} )}
{settings.maxAutoPlayEpisodeCount.value !== -1 && ( {settings.maxAutoPlayEpisodeCount.value !== -1 && (

View File

@@ -29,7 +29,6 @@ import Animated, {
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { import {
TVControlButton, TVControlButton,
@@ -82,7 +81,6 @@ interface Props {
subtitleIndex?: number; subtitleIndex?: number;
onAudioIndexChange?: (index: number) => void; onAudioIndexChange?: (index: number) => void;
onSubtitleIndexChange?: (index: number) => void; onSubtitleIndexChange?: (index: number) => void;
onBitrateChange?: (bitrate: number | undefined) => void;
previousItem?: BaseItemDto | null; previousItem?: BaseItemDto | null;
nextItem?: BaseItemDto | null; nextItem?: BaseItemDto | null;
goToPreviousItem?: () => void; goToPreviousItem?: () => void;
@@ -209,7 +207,6 @@ export const Controls: FC<Props> = ({
subtitleIndex, subtitleIndex,
onAudioIndexChange, onAudioIndexChange,
onSubtitleIndexChange, onSubtitleIndexChange,
onBitrateChange,
previousItem, previousItem,
nextItem: nextItemProp, nextItem: nextItemProp,
goToPreviousItem, goToPreviousItem,
@@ -258,7 +255,7 @@ export const Controls: FC<Props> = ({
const { subtitleTracks: videoContextSubtitleTracks } = useVideoContext(); const { subtitleTracks: videoContextSubtitleTracks } = useVideoContext();
// Track which button should have preferred focus when controls show // Track which button should have preferred focus when controls show
type LastModalType = "audio" | "subtitle" | "quality" | "techInfo" | null; type LastModalType = "audio" | "subtitle" | "techInfo" | null;
const [lastOpenedModal, setLastOpenedModal] = useState<LastModalType>(null); const [lastOpenedModal, setLastOpenedModal] = useState<LastModalType>(null);
// Track if play button should have focus (when showing controls via up/down D-pad) // Track if play button should have focus (when showing controls via up/down D-pad)
@@ -307,24 +304,6 @@ export const Controls: FC<Props> = ({
[onAudioIndexChange], [onAudioIndexChange],
); );
// Quality options mirror the mobile menu: value is the max bitrate as a
// string, "" meaning Max (no limit) — same encoding as the bitrateValue
// route param, so selection matching is a plain string compare.
const bitrateOptions: TVOptionItem<string>[] = useMemo(() => {
return BITRATES.map((bitrate) => ({
label: bitrate.key,
value: bitrate.value?.toString() ?? "",
selected: (bitrateValue ?? "") === (bitrate.value?.toString() ?? ""),
}));
}, [bitrateValue]);
const handleBitrateChange = useCallback(
(value: string) => {
onBitrateChange?.(value ? Number.parseInt(value, 10) : undefined);
},
[onBitrateChange],
);
const _handleSubtitleChange = useCallback( const _handleSubtitleChange = useCallback(
(index: number) => { (index: number) => {
onSubtitleIndexChange?.(index); onSubtitleIndexChange?.(index);
@@ -595,19 +574,6 @@ export const Controls: FC<Props> = ({
controlsInteractionRef.current(); controlsInteractionRef.current();
}, [showOptions, t, audioOptions, handleAudioChange]); }, [showOptions, t, audioOptions, handleAudioChange]);
const handleOpenQualitySheet = useCallback(() => {
setLastOpenedModal("quality");
showOptions({
title: t("item_card.quality"),
options: bitrateOptions,
onSelect: handleBitrateChange,
// Changing quality replaces the player route (stream re-negotiation);
// apply it after the modal is dismissed so it isn't swallowed.
deferApplyUntilDismissed: true,
});
controlsInteractionRef.current();
}, [showOptions, t, bitrateOptions, handleBitrateChange]);
const handleLocalSubtitleDownloaded = useCallback( const handleLocalSubtitleDownloaded = useCallback(
(path: string) => { (path: string) => {
addSubtitleFile?.(path); addSubtitleFile?.(path);
@@ -1405,17 +1371,6 @@ export const Controls: FC<Props> = ({
<View style={styles.controlButtonsSpacer} /> <View style={styles.controlButtonsSpacer} />
{onBitrateChange && !offline && !isLiveTV && (
<TVControlButton
icon='speedometer'
onPress={handleOpenQualitySheet}
hasTVPreferredFocus={
!isCountdownActive && lastOpenedModal === "quality"
}
size={24}
/>
)}
{audioOptions.length > 0 && ( {audioOptions.length > 0 && (
<TVControlButton <TVControlButton
icon='volume-high' icon='volume-high'

View File

@@ -0,0 +1,125 @@
import type { FC } from "react";
import { useEffect, useState } from "react";
import { StyleSheet } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import SkipButton from "./SkipButton";
interface Props {
showSkipButton: boolean;
showSkipCreditButton: boolean;
hasContentAfterCredits: boolean;
willShowNextEpisode: boolean;
skipIntro: () => void;
skipCredit: () => void;
controlsVisible: boolean;
}
// Offsets are relative to the safe-area insets so they hold in both portrait
// and landscape (the insets move with the notch / home indicator).
//
// Hidden: low, far-right — nothing else is drawn there, this is the familiar
// spot and was working fine.
// Visible: shifted up (clear of the horizontal progress bar) and left (clear
// of the chapters icon), while staying below the vertical volume
// slider which sits at the vertical middle of the screen.
const HIDDEN_BOTTOM = 24;
const HIDDEN_RIGHT = 12;
const VISIBLE_BOTTOM = 65;
const VISIBLE_RIGHT = 42;
const ANIM_DURATION = 250;
// Keeps `value` true for `duration` ms after it turns false. SkipButton hides
// itself instantly via a `hidden` (display:none) class, which would preempt
// the parent's opacity fade-out — lagging the flag keeps the button rendered
// (and visible) while the fade plays out.
const useDelayedHide = (value: boolean, duration: number): boolean => {
const [display, setDisplay] = useState(value);
useEffect(() => {
if (value) {
setDisplay(true);
return;
}
const t = setTimeout(() => setDisplay(false), duration);
return () => clearTimeout(t);
}, [value, duration]);
return value || display;
};
/**
* Floating Skip Intro / Skip Credits buttons shown independently of the
* player controls. They appear on their own during an intro or credits segment
* without the user having to summon the controls.
*/
export const SkipSegmentOverlay: FC<Props> = ({
showSkipButton,
showSkipCreditButton,
hasContentAfterCredits,
willShowNextEpisode,
skipIntro,
skipCredit,
controlsVisible,
}) => {
const insets = useControlsSafeAreaInsets();
const showCredit =
showSkipCreditButton && (hasContentAfterCredits || !willShowNextEpisode);
const visible = showSkipButton || showCredit;
// Drive each SkipButton with a lagged flag so it stays visible while the
// opacity fade-out plays, instead of disappearing the instant its segment
// ends. `visible` above still drives opacity/pointerEvents immediately.
const renderSkip = useDelayedHide(showSkipButton, ANIM_DURATION);
const renderCredit = useDelayedHide(showCredit, ANIM_DURATION);
const opacity = useSharedValue(visible ? 1 : 0);
useEffect(() => {
opacity.value = withTiming(visible ? 1 : 0, {
duration: ANIM_DURATION,
easing: Easing.out(Easing.quad),
});
}, [visible, opacity]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
// Position is recomputed on render (no slide animation) so the button never
// sweeps through an overlap zone while the controls toggle.
const bottom =
insets.bottom + (controlsVisible ? VISIBLE_BOTTOM : HIDDEN_BOTTOM);
const right = insets.right + (controlsVisible ? VISIBLE_RIGHT : HIDDEN_RIGHT);
return (
<Animated.View
style={[styles.container, { right, bottom }, animatedStyle]}
pointerEvents={visible ? "box-none" : "none"}
>
<SkipButton
showButton={renderSkip}
onPress={skipIntro}
buttonText='Skip Intro'
/>
<SkipButton
showButton={renderCredit}
onPress={skipCredit}
buttonText='Skip Credits'
/>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
position: "absolute",
flexDirection: "row",
gap: 8,
zIndex: 15,
},
});