mirror of
https://github.com/p-stream/p-stream.git
synced 2026-05-12 09:11:10 +00:00
fix skip section buttons not showing
This commit is contained in:
parent
64a241e2ed
commit
a9e2ff2dc6
3 changed files with 174 additions and 126 deletions
|
|
@ -96,6 +96,8 @@ export function NextEpisodeButton(props: {
|
||||||
onChange?: (meta: PlayerMeta) => void;
|
onChange?: (meta: PlayerMeta) => void;
|
||||||
inControl: boolean;
|
inControl: boolean;
|
||||||
showAsButton?: boolean;
|
showAsButton?: boolean;
|
||||||
|
/** When true (e.g. in credits-to-end segment), show regardless of time/duration. */
|
||||||
|
forceShow?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const duration = usePlayerStore((s) => s.progress.duration);
|
const duration = usePlayerStore((s) => s.progress.duration);
|
||||||
|
|
@ -109,7 +111,8 @@ export function NextEpisodeButton(props: {
|
||||||
const setLastSuccessfulSource = usePreferencesStore(
|
const setLastSuccessfulSource = usePreferencesStore(
|
||||||
(s) => s.setLastSuccessfulSource,
|
(s) => s.setLastSuccessfulSource,
|
||||||
);
|
);
|
||||||
const showingState = shouldShowNextEpisodeButton(time, duration);
|
const timeBasedState = shouldShowNextEpisodeButton(time, duration);
|
||||||
|
const showingState = props.forceShow ? "always" : timeBasedState;
|
||||||
const status = usePlayerStore((s) => s.status);
|
const status = usePlayerStore((s) => s.status);
|
||||||
const setShouldStartFromBeginning = usePlayerStore(
|
const setShouldStartFromBeginning = usePlayerStore(
|
||||||
(s) => s.setShouldStartFromBeginning,
|
(s) => s.setShouldStartFromBeginning,
|
||||||
|
|
|
||||||
|
|
@ -86,18 +86,18 @@ function SkipSegmentButton(props: {
|
||||||
const meta = usePlayerStore((s) => s.meta);
|
const meta = usePlayerStore((s) => s.meta);
|
||||||
const { addSkipEvent } = useSkipTracking(20);
|
const { addSkipEvent } = useSkipTracking(20);
|
||||||
|
|
||||||
// Check if we should show NextEpisodeButton instead of credits skip button
|
// Only replace with NextEpisodeButton when credits have no end (end_ms === null) – i.e. credits
|
||||||
|
// run to the end of the video. When end_ms is a number, there may be content after (e.g. post-
|
||||||
|
// credits scene), so we show the normal "Skip credits" button that seeks to end_ms.
|
||||||
const shouldShowNextEpisodeInsteadOfCredits =
|
const shouldShowNextEpisodeInsteadOfCredits =
|
||||||
meta?.type === "show" &&
|
meta?.type === "show" &&
|
||||||
props.segments.some((segment) => {
|
props.segments.some((segment) => {
|
||||||
if (segment.type !== "credits") return false;
|
if (segment.type !== "credits") return false;
|
||||||
// Show NextEpisodeButton if credits end at video end (null means end of video)
|
|
||||||
return segment.end_ms === null;
|
return segment.end_ms === null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find segments that should be shown at the current time
|
// Find segments that should be shown at the current time (intro, recap; credits excluded when we show NextEpisodeButton)
|
||||||
const activeSegments = props.segments.filter((segment) => {
|
const activeSegments = props.segments.filter((segment) => {
|
||||||
// Skip credits segments if we're showing NextEpisodeButton instead
|
|
||||||
if (segment.type === "credits" && shouldShowNextEpisodeInsteadOfCredits) {
|
if (segment.type === "credits" && shouldShowNextEpisodeInsteadOfCredits) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -105,6 +105,17 @@ function SkipSegmentButton(props: {
|
||||||
return showingState !== "none";
|
return showingState !== "none";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// NextEpisodeButton only for the "credits to end of video" segment (end_ms === null)
|
||||||
|
const creditsSegment = props.segments.find(
|
||||||
|
(s) => s.type === "credits" && s.end_ms === null,
|
||||||
|
);
|
||||||
|
const inCreditsSegment =
|
||||||
|
creditsSegment != null && time * 1000 >= (creditsSegment.start_ms ?? 0);
|
||||||
|
const showNextEpisodeButton =
|
||||||
|
shouldShowNextEpisodeInsteadOfCredits &&
|
||||||
|
props.inControl &&
|
||||||
|
inCreditsSegment;
|
||||||
|
|
||||||
const handleSkip = useCallback(
|
const handleSkip = useCallback(
|
||||||
(segment: SegmentData) => {
|
(segment: SegmentData) => {
|
||||||
if (!display) return;
|
if (!display) return;
|
||||||
|
|
@ -146,70 +157,70 @@ function SkipSegmentButton(props: {
|
||||||
[display, time, _duration, addSkipEvent, meta, props],
|
[display, time, _duration, addSkipEvent, meta, props],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show NextEpisodeButton instead of credits skip button for TV shows when credits end at video end
|
if (!props.inControl) return null;
|
||||||
if (shouldShowNextEpisodeInsteadOfCredits && props.inControl) {
|
|
||||||
return (
|
|
||||||
<NextEpisodeButton
|
|
||||||
controlsShowing={props.controlsShowing}
|
|
||||||
onChange={props.onChangeMeta}
|
|
||||||
inControl={props.inControl}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!props.inControl || activeSegments.length === 0) return null;
|
|
||||||
|
|
||||||
// If status is not playing, don't show buttons
|
|
||||||
if (status !== "playing") return null;
|
if (status !== "playing") return null;
|
||||||
|
if (activeSegments.length === 0 && !showNextEpisodeButton) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute right-[calc(3rem+env(safe-area-inset-right))] bottom-0">
|
<>
|
||||||
{activeSegments.map((segment, index) => {
|
<div className="absolute right-[calc(3rem+env(safe-area-inset-right))] bottom-0">
|
||||||
const showingState = shouldShowSkipButton(time, segment);
|
{activeSegments.map((segment, index) => {
|
||||||
const animation = showingState === "hover" ? "slide-up" : "fade";
|
const showingState = shouldShowSkipButton(time, segment);
|
||||||
|
const animation = showingState === "hover" ? "slide-up" : "fade";
|
||||||
|
|
||||||
let bottom = "bottom-[calc(6rem+env(safe-area-inset-bottom))]";
|
let bottom = "bottom-[calc(6rem+env(safe-area-inset-bottom))]";
|
||||||
if (showingState === "always") {
|
if (showingState === "always") {
|
||||||
bottom = props.controlsShowing
|
bottom = props.controlsShowing
|
||||||
? bottom
|
? bottom
|
||||||
: "bottom-[calc(3rem+env(safe-area-inset-bottom))]";
|
: "bottom-[calc(3rem+env(safe-area-inset-bottom))]";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Offset multiple buttons vertically
|
// Offset multiple buttons vertically
|
||||||
const verticalOffset = index * 60; // 60px spacing between buttons
|
const verticalOffset = index * 60; // 60px spacing between buttons
|
||||||
const adjustedBottom = bottom.replace(
|
const adjustedBottom = bottom.replace(
|
||||||
/bottom-\[calc\(([^)]+)\)\]/,
|
/bottom-\[calc\(([^)]+)\)\]/,
|
||||||
`bottom-[calc($1 + ${verticalOffset}px)]`,
|
`bottom-[calc($1 + ${verticalOffset}px)]`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show button whenever we're in a segment (not only on hover after first 10s)
|
let show = false;
|
||||||
const show = showingState === "always" || showingState === "hover";
|
if (showingState === "always") show = true;
|
||||||
|
else if (showingState === "hover" && props.controlsShowing)
|
||||||
|
show = true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
key={segment.type}
|
key={segment.type}
|
||||||
animation={animation}
|
animation={animation}
|
||||||
show={show}
|
show={show}
|
||||||
className="absolute right-0"
|
className="absolute right-0"
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames([
|
|
||||||
"absolute bottom-0 right-0 transition-[bottom] duration-200 flex items-center space-x-3",
|
|
||||||
adjustedBottom,
|
|
||||||
])}
|
|
||||||
>
|
>
|
||||||
<Button
|
<div
|
||||||
onClick={() => handleSkip(segment)}
|
className={classNames([
|
||||||
className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center"
|
"absolute bottom-0 right-0 transition-[bottom] duration-200 flex items-center space-x-3",
|
||||||
|
adjustedBottom,
|
||||||
|
])}
|
||||||
>
|
>
|
||||||
<Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} />
|
<Button
|
||||||
{getSegmentText(segment.type, t)}
|
onClick={() => handleSkip(segment)}
|
||||||
</Button>
|
className="bg-buttons-primary hover:bg-buttons-primaryHover text-buttons-primaryText flex justify-center items-center"
|
||||||
</div>
|
>
|
||||||
</Transition>
|
<Icon className="text-xl mr-1" icon={Icons.SKIP_EPISODE} />
|
||||||
);
|
{getSegmentText(segment.type, t)}
|
||||||
})}
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{showNextEpisodeButton && (
|
||||||
|
<NextEpisodeButton
|
||||||
|
controlsShowing={props.controlsShowing}
|
||||||
|
onChange={props.onChangeMeta}
|
||||||
|
inControl={props.inControl}
|
||||||
|
forceShow
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useEffect } from "react";
|
||||||
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
|
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
|
||||||
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
|
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
|
||||||
import { conf } from "@/setup/config";
|
import { conf } from "@/setup/config";
|
||||||
import { getMediaKey } from "@/stores/player/slices/source";
|
import type { PlayerMeta } from "@/stores/player/slices/source";
|
||||||
import { usePlayerStore } from "@/stores/player/store";
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
import { usePreferencesStore } from "@/stores/preferences";
|
import { usePreferencesStore } from "@/stores/preferences";
|
||||||
import { getTurnstileToken } from "@/utils/turnstile";
|
import { getTurnstileToken } from "@/utils/turnstile";
|
||||||
|
|
@ -18,6 +18,19 @@ const MAX_RETRIES = 3;
|
||||||
// Track the source of the current skip time (for analytics filtering)
|
// Track the source of the current skip time (for analytics filtering)
|
||||||
let currentSkipTimeSource: "fed-skips" | "introdb" | "theintrodb" | null = null;
|
let currentSkipTimeSource: "fed-skips" | "introdb" | "theintrodb" | null = null;
|
||||||
|
|
||||||
|
// Prevent multiple components from triggering overlapping fetches for the same media
|
||||||
|
let fetchingForCacheKey: string | null = null;
|
||||||
|
|
||||||
|
/** Cache key for skip segments – matches TIDB API (tmdbId + season + episode number). */
|
||||||
|
function getSkipSegmentsCacheKey(meta: PlayerMeta | null): string | null {
|
||||||
|
if (!meta?.tmdbId) return null;
|
||||||
|
if (meta.type === "movie") return `skip-${meta.type}-${meta.tmdbId}`;
|
||||||
|
if (meta.type === "show" && meta.season != null && meta.episode != null) {
|
||||||
|
return `skip-${meta.type}-${meta.tmdbId}-${meta.season.number}-${meta.episode.number}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function useSkipTimeSource(): typeof currentSkipTimeSource {
|
export function useSkipTimeSource(): typeof currentSkipTimeSource {
|
||||||
return currentSkipTimeSource;
|
return currentSkipTimeSource;
|
||||||
}
|
}
|
||||||
|
|
@ -33,7 +46,7 @@ export interface SegmentData {
|
||||||
export function useSkipTime() {
|
export function useSkipTime() {
|
||||||
const { playerMeta: meta } = usePlayerMeta();
|
const { playerMeta: meta } = usePlayerMeta();
|
||||||
const febboxKey = usePreferencesStore((s) => s.febboxKey);
|
const febboxKey = usePreferencesStore((s) => s.febboxKey);
|
||||||
const cacheKey = getMediaKey(meta ?? null);
|
const cacheKey = getSkipSegmentsCacheKey(meta ?? null);
|
||||||
const skipSegmentsCacheKey = usePlayerStore((s) => s.skipSegmentsCacheKey);
|
const skipSegmentsCacheKey = usePlayerStore((s) => s.skipSegmentsCacheKey);
|
||||||
const skipSegments = usePlayerStore((s) => s.skipSegments);
|
const skipSegments = usePlayerStore((s) => s.skipSegments);
|
||||||
const setSkipSegments = usePlayerStore((s) => s.setSkipSegments);
|
const setSkipSegments = usePlayerStore((s) => s.setSkipSegments);
|
||||||
|
|
@ -41,7 +54,10 @@ export function useSkipTime() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cacheKey) return;
|
if (!cacheKey) return;
|
||||||
// Already have segments for this media – don't refetch (e.g. when opening menu)
|
// Already have segments for this media – don't refetch (e.g. when opening menu)
|
||||||
if (cacheKey === skipSegmentsCacheKey) return;
|
if (usePlayerStore.getState().skipSegmentsCacheKey === cacheKey) return;
|
||||||
|
// Another fetch for this key is already in progress (e.g. two components mounted)
|
||||||
|
if (fetchingForCacheKey === cacheKey) return;
|
||||||
|
fetchingForCacheKey = cacheKey;
|
||||||
// Validate segment data according to rules
|
// Validate segment data according to rules
|
||||||
// eslint-disable-next-line camelcase
|
// eslint-disable-next-line camelcase
|
||||||
const validateSegment = (
|
const validateSegment = (
|
||||||
|
|
@ -86,8 +102,11 @@ export function useSkipTime() {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchTheIntroDBSegments = async (): Promise<SegmentData[]> => {
|
const fetchTheIntroDBSegments = async (): Promise<{
|
||||||
if (!meta?.tmdbId) return [];
|
segments: SegmentData[];
|
||||||
|
tidbNotFound: boolean;
|
||||||
|
}> => {
|
||||||
|
if (!meta?.tmdbId) return { segments: [], tidbNotFound: false };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let apiUrl = `${THE_INTRO_DB_BASE_URL}/media?tmdb_id=${meta.tmdbId}`;
|
let apiUrl = `${THE_INTRO_DB_BASE_URL}/media?tmdb_id=${meta.tmdbId}`;
|
||||||
|
|
@ -148,10 +167,19 @@ export function useSkipTime() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetchedSegments;
|
// TIDB returned 200 – we have segment data for this media (even if no intro)
|
||||||
} catch (error) {
|
return { segments: fetchedSegments, tidbNotFound: false };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as {
|
||||||
|
response?: { status?: number };
|
||||||
|
status?: number;
|
||||||
|
};
|
||||||
|
const status = err?.response?.status ?? err?.status;
|
||||||
|
if (status === 404) {
|
||||||
|
return { segments: [], tidbNotFound: true };
|
||||||
|
}
|
||||||
console.error("Error fetching TIDB segments:", error);
|
console.error("Error fetching TIDB segments:", error);
|
||||||
return [];
|
return { segments: [], tidbNotFound: false };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -219,77 +247,82 @@ export function useSkipTime() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applySegments = (segmentsToApply: SegmentData[]) => {
|
||||||
|
// Only update store if this fetch is still for the current media (avoid stale overwrite)
|
||||||
|
const currentKey = getSkipSegmentsCacheKey(
|
||||||
|
usePlayerStore.getState().meta ?? null,
|
||||||
|
);
|
||||||
|
if (currentKey === cacheKey) {
|
||||||
|
setSkipSegments(cacheKey, segmentsToApply);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchSkipTime = async (): Promise<void> => {
|
const fetchSkipTime = async (): Promise<void> => {
|
||||||
currentSkipTimeSource = null;
|
currentSkipTimeSource = null;
|
||||||
|
|
||||||
// Try TheIntroDB API first (supports both movies and TV shows with full segment data)
|
try {
|
||||||
const theIntroDBSegments = await fetchTheIntroDBSegments();
|
// Try TheIntroDB API first (supports both movies and TV shows with full segment data)
|
||||||
const hasIntroSegment = theIntroDBSegments.some(
|
const { segments: tidbSegments, tidbNotFound } =
|
||||||
(s) => s.type === "intro",
|
await fetchTheIntroDBSegments();
|
||||||
);
|
|
||||||
const nonIntroSegments = theIntroDBSegments.filter(
|
|
||||||
(s) => s.type !== "intro",
|
|
||||||
);
|
|
||||||
|
|
||||||
// If we have a valid intro from TIDB, use all TIDB segments
|
// TIDB returned 200 – use whatever segments we got (intro, recap, credits; may be empty)
|
||||||
if (hasIntroSegment) {
|
if (!tidbNotFound) {
|
||||||
currentSkipTimeSource = "theintrodb";
|
currentSkipTimeSource = "theintrodb";
|
||||||
setSkipSegments(cacheKey, theIntroDBSegments);
|
applySegments(tidbSegments);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If TIDB doesn't have a valid intro, try fallbacks to get intro data
|
// TIDB returned 404 – no segment data for this media; try fallbacks for intro only
|
||||||
// But keep any valid recap/credits segments from TIDB
|
const nonIntroSegments: SegmentData[] = [];
|
||||||
let fallbackIntroSegment: SegmentData | null = null;
|
let fallbackIntroSegment: SegmentData | null = null;
|
||||||
|
|
||||||
// Fall back to Fed-skips if TheIntroDB doesn't have intro
|
// Fall back to Fed-skips (TV shows only)
|
||||||
// Note: Fed-skips only supports TV shows, not movies
|
if (febboxKey && meta?.type !== "movie") {
|
||||||
if (febboxKey && meta?.type !== "movie") {
|
const fedSkipsTime = await fetchFedSkipsTime();
|
||||||
const fedSkipsTime = await fetchFedSkipsTime();
|
if (fedSkipsTime !== null) {
|
||||||
if (fedSkipsTime !== null) {
|
currentSkipTimeSource = "fed-skips";
|
||||||
currentSkipTimeSource = "fed-skips";
|
fallbackIntroSegment = {
|
||||||
fallbackIntroSegment = {
|
type: "intro",
|
||||||
type: "intro",
|
start_ms: 0,
|
||||||
start_ms: 0, // Assume starts at beginning
|
end_ms: fedSkipsTime * 1000,
|
||||||
end_ms: fedSkipsTime * 1000, // Convert seconds to milliseconds
|
confidence: null,
|
||||||
confidence: null,
|
submission_count: 1,
|
||||||
submission_count: 1,
|
};
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: IntroDB API (TV shows only)
|
||||||
|
if (!fallbackIntroSegment && meta?.type !== "movie") {
|
||||||
|
const introDBTime = await fetchIntroDBTime();
|
||||||
|
if (introDBTime !== null) {
|
||||||
|
currentSkipTimeSource = "introdb";
|
||||||
|
fallbackIntroSegment = {
|
||||||
|
type: "intro",
|
||||||
|
start_ms: 0,
|
||||||
|
end_ms: introDBTime * 1000,
|
||||||
|
confidence: null,
|
||||||
|
submission_count: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalSegments: SegmentData[] = [];
|
||||||
|
if (fallbackIntroSegment) {
|
||||||
|
finalSegments.push(fallbackIntroSegment);
|
||||||
|
}
|
||||||
|
finalSegments.push(...nonIntroSegments);
|
||||||
|
|
||||||
|
applySegments(finalSegments);
|
||||||
|
} finally {
|
||||||
|
if (fetchingForCacheKey === cacheKey) {
|
||||||
|
fetchingForCacheKey = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last resort: Fall back to IntroDB API (TV shows only, available to all users)
|
|
||||||
if (!fallbackIntroSegment) {
|
|
||||||
const introDBTime = await fetchIntroDBTime();
|
|
||||||
if (introDBTime !== null) {
|
|
||||||
currentSkipTimeSource = "introdb";
|
|
||||||
fallbackIntroSegment = {
|
|
||||||
type: "intro",
|
|
||||||
start_ms: 0, // Assume starts at beginning
|
|
||||||
end_ms: introDBTime * 1000, // Convert seconds to milliseconds
|
|
||||||
confidence: null,
|
|
||||||
submission_count: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine fallback intro with any valid TIDB segments (recap/credits)
|
|
||||||
const finalSegments: SegmentData[] = [];
|
|
||||||
if (fallbackIntroSegment) {
|
|
||||||
finalSegments.push(fallbackIntroSegment);
|
|
||||||
}
|
|
||||||
// Add any valid recap/credits segments from TIDB
|
|
||||||
finalSegments.push(...nonIntroSegments);
|
|
||||||
|
|
||||||
// Always update cache (even when empty) so we don't refetch for this media
|
|
||||||
setSkipSegments(cacheKey, finalSegments);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchSkipTime();
|
fetchSkipTime();
|
||||||
}, [
|
}, [
|
||||||
cacheKey,
|
cacheKey,
|
||||||
skipSegmentsCacheKey,
|
|
||||||
setSkipSegments,
|
|
||||||
meta?.tmdbId,
|
meta?.tmdbId,
|
||||||
meta?.imdbId,
|
meta?.imdbId,
|
||||||
meta?.title,
|
meta?.title,
|
||||||
|
|
@ -297,6 +330,7 @@ export function useSkipTime() {
|
||||||
meta?.season?.number,
|
meta?.season?.number,
|
||||||
meta?.episode?.number,
|
meta?.episode?.number,
|
||||||
febboxKey,
|
febboxKey,
|
||||||
|
setSkipSegments,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Only return segments when they're for the current media (avoid showing stale data)
|
// Only return segments when they're for the current media (avoid showing stale data)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue