Compare commits

...

6 commits

Author SHA1 Message Date
vlOd
fedd414629
Merge branch 'production' into production 2026-01-10 14:30:59 +02:00
Pas
6331e69a2f fix quality switching bug 2026-01-08 13:19:21 -07:00
Pas
2f90617eee increase caption delay slider cap 2026-01-08 12:14:00 -07:00
Pas
2a103fc967 move febbox mp4 to within the collapse section 2026-01-07 19:31:22 -07:00
Pas
445fd373c1 remove "smart" skip detection
It really wasnt smart. Just report the skip time from the APIs
2026-01-07 15:38:51 -07:00
Pas
4024aecd40 Revert "thumbs up or down skip intros"
This reverts commit 7ea4b1d23b.
2026-01-07 15:00:04 -07:00
9 changed files with 89 additions and 265 deletions

View file

@ -735,10 +735,6 @@
"device": "device",
"enabled": "Casting to device 🎬"
},
"skipIntro": {
"feedback": "Was this skip helpful?",
"skip": "Skip Intro"
},
"menus": {
"downloads": {
"button": "Attempt download",

View file

@ -1,6 +1,5 @@
import classNames from "classnames";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useCallback } from "react";
import { Icon, Icons } from "@/components/Icon";
import { useSkipTracking } from "@/components/player/hooks/useSkipTracking";
@ -51,14 +50,6 @@ export function SkipIntroButton(props: {
const display = usePlayerStore((s) => s.display);
const meta = usePlayerStore((s) => s.meta);
const { addSkipEvent } = useSkipTracking(20);
const [showFeedback, setShowFeedback] = useState(false);
const [feedbackSubmitted, setFeedbackSubmitted] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingSkipDataRef = useRef<{
startTime: number;
endTime: number;
skipDuration: number;
} | null>(null);
const showingState = shouldShowSkipButton(time, props.skipTime);
const animation = showingState === "hover" ? "slide-up" : "fade";
let bottom = "bottom-[calc(6rem+env(safe-area-inset-bottom))]";
@ -68,19 +59,20 @@ export function SkipIntroButton(props: {
: "bottom-[calc(3rem+env(safe-area-inset-bottom))]";
}
const { t } = useTranslation();
const handleSkip = useCallback(() => {
if (typeof props.skipTime === "number" && display) {
const startTime = time;
const endTime = props.skipTime;
const skipDuration = endTime - startTime;
const reportSkip = useCallback(
(confidence: number) => {
if (!pendingSkipDataRef.current) return;
const { startTime, endTime, skipDuration } = pendingSkipDataRef.current;
display.setTime(props.skipTime);
// Add manual skip event with high confidence (user explicitly clicked skip intro)
addSkipEvent({
startTime,
endTime,
skipDuration,
confidence,
confidence: 0.95, // High confidence for explicit user action
meta: meta
? {
title:
@ -95,99 +87,15 @@ export function SkipIntroButton(props: {
: undefined,
});
// eslint-disable-next-line no-console
console.log(
`Skip intro reported: ${skipDuration}s total, confidence: ${confidence}`,
);
// Clean up
pendingSkipDataRef.current = null;
setShowFeedback(false);
setFeedbackSubmitted(true);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
},
[addSkipEvent, meta],
);
const handleThumbsUp = useCallback(() => {
reportSkip(0.95);
}, [reportSkip]);
const handleThumbsDown = useCallback(() => {
reportSkip(0.7);
}, [reportSkip]);
const handleSkip = useCallback(() => {
if (typeof props.skipTime === "number" && display) {
const startTime = time;
const endTime = props.skipTime;
const skipDuration = endTime - startTime;
display.setTime(props.skipTime);
// Store skip data temporarily
pendingSkipDataRef.current = {
startTime,
endTime,
skipDuration,
};
// Show feedback UI
setShowFeedback(true);
setFeedbackSubmitted(false);
// Start 10-second timeout
timeoutRef.current = setTimeout(() => {
// Hide component immediately to prevent flicker
setShowFeedback(false);
setFeedbackSubmitted(true);
reportSkip(0.8);
}, 10000);
// eslint-disable-next-line no-console
console.log(`Skip intro button used: ${skipDuration}s total`);
}
}, [props.skipTime, display, time, reportSkip]);
// Reset feedback state when content changes
useEffect(() => {
setShowFeedback(false);
setFeedbackSubmitted(false);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
pendingSkipDataRef.current = null;
}, [meta?.tmdbId]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
}, [props.skipTime, display, time, addSkipEvent, meta]);
if (!props.inControl) return null;
let show = false;
// Don't show anything if feedback has been submitted
if (feedbackSubmitted) {
show = false;
} else if (showFeedback) {
// Always show feedback UI when active
show = true;
} else if (showingState === "always") {
// Show skip button when always visible
show = true;
} else if (showingState === "hover" && props.controlsShowing) {
// Show skip button on hover when controls are showing
show = true;
}
if (showingState === "always") show = true;
else if (showingState === "hover" && props.controlsShowing) show = true;
if (status !== "playing") show = false;
return (
@ -198,52 +106,17 @@ export function SkipIntroButton(props: {
>
<div
className={classNames([
"absolute bottom-0 right-0 transition-[bottom] duration-200 flex items-center",
showFeedback ? "flex-col space-y-2" : "space-x-3",
"absolute bottom-0 right-0 transition-[bottom] duration-200 flex items-center space-x-3",
bottom,
])}
>
{showFeedback ? (
<>
<div className="text-sm font-medium text-white">
{t("player.skipIntro.feedback")}
</div>
<div className="flex items-center space-x-3">
<button
type="button"
onClick={handleThumbsUp}
className={classNames(
"h-10 w-10 rounded-full flex items-center justify-center",
"bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText",
"scale-95 hover:scale-100 transition-all duration-200",
)}
aria-label="Thumbs up"
>
<Icon className="text-xl" icon={Icons.THUMBS_UP} />
</button>
<button
type="button"
onClick={handleThumbsDown}
className={classNames(
"h-10 w-10 rounded-full flex items-center justify-center",
"bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText",
"scale-95 hover:scale-100 transition-all duration-200",
)}
aria-label="Thumbs down"
>
<Icon className="text-xl" icon={Icons.THUMBS_DOWN} />
</button>
</div>
</>
) : (
<Button
onClick={handleSkip}
className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center"
>
<Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} />
{t("player.skipIntro.skip")}
</Button>
)}
<Button
onClick={handleSkip}
className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center"
>
<Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} />
Skip Intro
</Button>
</div>
</Transition>
);

View file

@ -471,8 +471,8 @@ export function CaptionSettingsView({
</span>
<CaptionDelay
label={t("player.menus.subtitles.settings.delay")}
max={20}
min={-20}
max={40}
min={-40}
onChange={(v) => setDelay(v)}
value={delay}
textTransformer={(s) => `${s}s`}

View file

@ -40,6 +40,7 @@ function useIsIosHls() {
export function QualityView({ id }: { id: string }) {
const router = useOverlayRouter(id);
const isIosHls = useIsIosHls();
const sourceType = usePlayerStore((s) => s.source?.type);
const availableQualities = usePlayerStore((s) => s.qualities);
const currentQuality = usePlayerStore((s) => s.currentQuality);
const switchQuality = usePlayerStore((s) => s.switchQuality);
@ -50,14 +51,18 @@ export function QualityView({ id }: { id: string }) {
const setLastChosenQuality = useQualityStore((s) => s.setLastChosenQuality);
const autoQuality = useQualityStore((s) => s.quality.automaticQuality);
// Auto quality only makes sense for HLS sources
const supportsAutoQuality = sourceType === "hls";
const change = useCallback(
(q: SourceQuality) => {
setLastChosenQuality(q);
setAutomaticQuality(false);
// Don't disable auto quality when manually selecting a quality
// Keep auto quality enabled by default unless user explicitly toggles it
switchQuality(q);
router.close();
},
[router, switchQuality, setLastChosenQuality, setAutomaticQuality],
[router, switchQuality, setLastChosenQuality],
);
const changeAutomatic = useCallback(() => {
@ -90,12 +95,18 @@ export function QualityView({ id }: { id: string }) {
{qualityToString(v)}
</SelectableLink>
))}
<Menu.Divider />
<Menu.Link
rightSide={<Toggle onClick={changeAutomatic} enabled={autoQuality} />}
>
{t("player.menus.quality.automaticLabel")}
</Menu.Link>
{supportsAutoQuality && (
<>
<Menu.Divider />
<Menu.Link
rightSide={
<Toggle onClick={changeAutomatic} enabled={autoQuality} />
}
>
{t("player.menus.quality.automaticLabel")}
</Menu.Link>
</>
)}
<Menu.SmallText>
<Trans
i18nKey={

View file

@ -313,9 +313,24 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]);
emit("changedquality", quality);
} else {
// When automatic quality is disabled, re-lock to preferred quality
// This prevents HLS.js from switching levels unexpectedly
setupQualityForHls();
// When automatic quality is disabled, check if current level matches preferred quality
const currentQuality = hlsLevelToQuality(
hls.levels[hls.currentLevel],
);
const preferredQualityLevel = getPreferredQuality(
hlsLevelsToQualities(hls.levels),
{
lastChosenQuality: preferenceQuality,
automaticQuality: false,
},
);
// Only re-lock if the current level doesn't match our preferred quality
if (currentQuality !== preferredQualityLevel) {
setupQualityForHls();
} else {
// Emit the quality change since we're now at the correct level
emit("changedquality", currentQuality);
}
}
});
hls.on(Hls.Events.SUBTITLE_TRACK_LOADED, () => {

View file

@ -29,33 +29,8 @@ interface SkipTrackingResult {
}
/**
* Calculate confidence score for automatic skip detection
* Based on skip duration and timing within the video
*/
function calculateSkipConfidence(
skipDuration: number,
startTime: number,
duration: number,
): number {
// Duration confidence: longer skips are more confident
// 20s = 0.4, 40s = 0.6, 60s+ = 0.85
const durationConfidence = Math.min(0.85, 0.4 + (skipDuration - 20) * 0.01);
// Timing confidence: earlier skips are more confident
// Start time as percentage of total duration
const startPercentage = startTime / duration;
// Higher confidence for earlier starts (0% = 1.0, 20% = 0.8)
const timingConfidence = Math.max(0.7, 1.0 - startPercentage * 0.75);
// Combine factors (weighted average)
return durationConfidence * 0.6 + timingConfidence * 0.4;
}
/**
* Hook that tracks rapid skipping sessions where users accumulate 20+ seconds of forward
* movement within a 5-second window. Sessions continue until 8 seconds pass without
* any forward movement, then report the total skip distance. Ignores skips that start
* after 20% of video duration (unlikely to be intro skipping).
* Hook that tracks manual skip events and monitors user behavior patterns for confidence adjustment.
* Only processes skip events added via addSkipEvent (e.g., from skip intro button).
*
* @param minSkipThreshold Minimum total forward movement in 5-second window to start session (default: 20)
* @param maxHistory Maximum number of skip events to keep in history (default: 50)
@ -74,7 +49,6 @@ export function useSkipTracking(
// Get current player state
const progress = usePlayerStore((s) => s.progress);
const meta = usePlayerStore((s) => s.meta);
const duration = progress.duration;
const clearHistory = useCallback(() => {
setSkipHistory([]);
@ -149,60 +123,7 @@ export function useSkipTracking(
);
if (isInSkipSessionRef.current && recentEntries.length === 0) {
// Ignore skips that start after 20% of video duration (likely not intro skipping)
const twentyPercentMark = duration * 0.2;
if (skipSessionStartRef.current > twentyPercentMark) {
// Reset session state without creating event
isInSkipSessionRef.current = false;
skipSessionStartRef.current = 0;
sessionTotalRef.current = 0;
skipWindowRef.current = [];
return;
}
// Only report skips where end time is greater than start time
if (currentTime <= skipSessionStartRef.current) {
// Reset session state without creating event
isInSkipSessionRef.current = false;
skipSessionStartRef.current = 0;
sessionTotalRef.current = 0;
skipWindowRef.current = [];
return;
}
// Create skip event for completed session
const skipEvent: SkipEvent = {
startTime: skipSessionStartRef.current,
endTime: currentTime,
skipDuration: sessionTotalRef.current,
timestamp: now,
confidence: calculateSkipConfidence(
sessionTotalRef.current,
skipSessionStartRef.current,
duration,
),
meta: meta
? {
title:
meta.type === "show" && meta.episode
? `${meta.title} - S${meta.season?.number || 0}E${meta.episode.number || 0}`
: meta.title,
type: meta.type === "movie" ? "Movie" : "TV Show",
tmdbId: meta.tmdbId,
seasonNumber: meta.season?.number,
episodeNumber: meta.episode?.number,
}
: undefined,
};
setSkipHistory((prev) => {
const newHistory = [...prev, skipEvent];
return newHistory.length > maxHistory
? newHistory.slice(newHistory.length - maxHistory)
: newHistory;
});
// Reset session state
// Session ended - reset state but DON'T create skip event
isInSkipSessionRef.current = false;
skipSessionStartRef.current = 0;
sessionTotalRef.current = 0;
@ -210,7 +131,7 @@ export function useSkipTracking(
}
previousTimeRef.current = currentTime;
}, [progress.time, duration, meta, minSkipThreshold, maxHistory]);
}, [progress.time, minSkipThreshold]);
useEffect(() => {
// Monitor time changes every 100ms to catch rapid skipping

View file

@ -82,13 +82,13 @@ export function SkipTracker() {
return {
skip,
originalConfidence: skip.confidence,
startTime: progress.time,
startTime: skip.startTime,
endTime: skip.endTime,
hasBackwardMovement: false,
timer,
};
},
[progress.time, sendSkipAnalytics],
[sendSkipAnalytics],
);
useEffect(() => {

View file

@ -518,23 +518,23 @@ export function FebboxSetup({
</>
);
})()}
<div className="flex justify-between items-center gap-4 mt-6">
<div className="my-3">
<p className="max-w-[32rem] font-medium">
{t("fedapi.setup.useMp4")}
</p>
</div>
<div>
<Toggle
onClick={() =>
preferences.setFebboxUseMp4(!preferences.febboxUseMp4)
}
enabled={preferences.febboxUseMp4}
/>
</div>
</div>
</>
) : null}
<div className="flex justify-between items-center gap-4 mt-6">
<div className="my-3">
<p className="max-w-[32rem] font-medium">
{t("fedapi.setup.useMp4")}
</p>
</div>
<div>
<Toggle
onClick={() =>
preferences.setFebboxUseMp4(!preferences.febboxUseMp4)
}
enabled={preferences.febboxUseMp4}
/>
</div>
</div>
</SettingsCard>
<Modal id={exampleModal.id}>
<ModalCard>

View file

@ -99,7 +99,15 @@ export function selectQuality(
const availableQualities = Object.entries(source.qualities)
.filter((entry) => (entry[1].url.length ?? 0) > 0)
.map((entry) => entry[0]) as SourceQuality[];
const quality = getPreferredQuality(availableQualities, qualityPreferences);
// For file sources (MP4), always use manual quality selection since they don't support switching
const manualQualityPreferences = {
...qualityPreferences,
automaticQuality: false,
};
const quality = getPreferredQuality(
availableQualities,
manualQualityPreferences,
);
if (quality) {
const stream = source.qualities[quality];
if (stream) {