mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +00:00
Merge branch 'production' into production
This commit is contained in:
commit
fedd414629
9 changed files with 89 additions and 265 deletions
|
|
@ -735,10 +735,6 @@
|
|||
"device": "device",
|
||||
"enabled": "Casting to device 🎬"
|
||||
},
|
||||
"skipIntro": {
|
||||
"feedback": "Was this skip helpful?",
|
||||
"skip": "Skip Intro"
|
||||
},
|
||||
"menus": {
|
||||
"downloads": {
|
||||
"button": "Attempt download",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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`}
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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, () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue