mirror of
https://github.com/movie-web/movie-web.git
synced 2024-11-14 09:35:05 +01:00
Merge pull request #250 from frost768/subtitle-file-type-control
Subtitle tests and type controls
This commit is contained in:
commit
b9448b5231
@ -32,7 +32,7 @@
|
|||||||
"react-stickynode": "^4.1.0",
|
"react-stickynode": "^4.1.0",
|
||||||
"react-transition-group": "^4.4.5",
|
"react-transition-group": "^4.4.5",
|
||||||
"react-use": "^17.4.0",
|
"react-use": "^17.4.0",
|
||||||
"subsrt-ts": "^2.1.0",
|
"subsrt-ts": "^2.1.1",
|
||||||
"unpacker": "^1.0.1"
|
"unpacker": "^1.0.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
152
src/__tests__/subtitles/subtitles.test.ts
Normal file
152
src/__tests__/subtitles/subtitles.test.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { describe, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getMWCaptionTypeFromUrl,
|
||||||
|
isSupportedSubtitle,
|
||||||
|
parseSubtitles,
|
||||||
|
} from "@/backend/helpers/captions";
|
||||||
|
import { MWCaptionType } from "@/backend/helpers/streams";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ass,
|
||||||
|
multilineSubtitlesTestVtt,
|
||||||
|
srt,
|
||||||
|
visibleSubtitlesTestVtt,
|
||||||
|
vtt,
|
||||||
|
} from "./testdata";
|
||||||
|
|
||||||
|
describe("subtitles", () => {
|
||||||
|
it("should return true if given url ends with a known subtitle type", ({
|
||||||
|
expect,
|
||||||
|
}) => {
|
||||||
|
expect(isSupportedSubtitle("https://example.com/test.srt")).toBe(true);
|
||||||
|
expect(isSupportedSubtitle("https://example.com/test.vtt")).toBe(true);
|
||||||
|
expect(isSupportedSubtitle("https://example.com/test.txt")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return corresponding MWCaptionType", ({ expect }) => {
|
||||||
|
expect(getMWCaptionTypeFromUrl("https://example.com/test.srt")).toBe(
|
||||||
|
MWCaptionType.SRT
|
||||||
|
);
|
||||||
|
expect(getMWCaptionTypeFromUrl("https://example.com/test.vtt")).toBe(
|
||||||
|
MWCaptionType.VTT
|
||||||
|
);
|
||||||
|
expect(getMWCaptionTypeFromUrl("https://example.com/test.txt")).toBe(
|
||||||
|
MWCaptionType.UNKNOWN
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when empty text is given", ({ expect }) => {
|
||||||
|
expect(() => parseSubtitles("")).toThrow("Given text is empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse srt", ({ expect }) => {
|
||||||
|
const parsed = parseSubtitles(srt);
|
||||||
|
const parsedSrt = [
|
||||||
|
{
|
||||||
|
type: "caption",
|
||||||
|
index: 1,
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
duration: 0,
|
||||||
|
content: "Test",
|
||||||
|
text: "Test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "caption",
|
||||||
|
index: 2,
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
duration: 0,
|
||||||
|
content: "Test",
|
||||||
|
text: "Test",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(parsed).toHaveLength(2);
|
||||||
|
expect(parsed).toEqual(parsedSrt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse vtt", ({ expect }) => {
|
||||||
|
const parsed = parseSubtitles(vtt);
|
||||||
|
const parsedVtt = [
|
||||||
|
{
|
||||||
|
type: "caption",
|
||||||
|
index: 1,
|
||||||
|
start: 0,
|
||||||
|
end: 4000,
|
||||||
|
duration: 4000,
|
||||||
|
content: "Where did he go?",
|
||||||
|
text: "Where did he go?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "caption",
|
||||||
|
index: 2,
|
||||||
|
start: 3000,
|
||||||
|
end: 6500,
|
||||||
|
duration: 3500,
|
||||||
|
content: "I think he went down this lane.",
|
||||||
|
text: "I think he went down this lane.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "caption",
|
||||||
|
index: 3,
|
||||||
|
start: 4000,
|
||||||
|
end: 6500,
|
||||||
|
duration: 2500,
|
||||||
|
content: "What are you waiting for?",
|
||||||
|
text: "What are you waiting for?",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(parsed).toHaveLength(3);
|
||||||
|
expect(parsed).toEqual(parsedVtt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse ass", ({ expect }) => {
|
||||||
|
const parsed = parseSubtitles(ass);
|
||||||
|
expect(parsed).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should delay subtitles when given a delay", ({ expect }) => {
|
||||||
|
const videoTime = 11;
|
||||||
|
let delayedSeconds = 0;
|
||||||
|
const parsed = parseSubtitles(visibleSubtitlesTestVtt);
|
||||||
|
const isVisible = (start: number, end: number, delay: number): boolean => {
|
||||||
|
const delayedStart = start / 1000 + delay;
|
||||||
|
const delayedEnd = end / 1000 + delay;
|
||||||
|
return (
|
||||||
|
Math.max(0, delayedStart) <= videoTime &&
|
||||||
|
Math.max(0, delayedEnd) >= videoTime
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const visibleSubtitles = parsed.filter((c) =>
|
||||||
|
isVisible(c.start, c.end, delayedSeconds)
|
||||||
|
);
|
||||||
|
expect(visibleSubtitles).toHaveLength(1);
|
||||||
|
|
||||||
|
delayedSeconds = 10;
|
||||||
|
const delayedVisibleSubtitles = parsed.filter((c) =>
|
||||||
|
isVisible(c.start, c.end, delayedSeconds)
|
||||||
|
);
|
||||||
|
expect(delayedVisibleSubtitles).toHaveLength(1);
|
||||||
|
|
||||||
|
delayedSeconds = -10;
|
||||||
|
const delayedVisibleSubtitles2 = parsed.filter((c) =>
|
||||||
|
isVisible(c.start, c.end, delayedSeconds)
|
||||||
|
);
|
||||||
|
expect(delayedVisibleSubtitles2).toHaveLength(1);
|
||||||
|
|
||||||
|
delayedSeconds = -20;
|
||||||
|
const delayedVisibleSubtitles3 = parsed.filter((c) =>
|
||||||
|
isVisible(c.start, c.end, delayedSeconds)
|
||||||
|
);
|
||||||
|
expect(delayedVisibleSubtitles3).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse multiline captions", ({ expect }) => {
|
||||||
|
const parsed = parseSubtitles(multilineSubtitlesTestVtt);
|
||||||
|
|
||||||
|
expect(parsed[0].text).toBe(`- Test 1\n- Test 2\n- Test 3`);
|
||||||
|
expect(parsed[1].text).toBe(`- Test 4`);
|
||||||
|
expect(parsed[2].text).toBe(`- Test 6`);
|
||||||
|
});
|
||||||
|
});
|
68
src/__tests__/subtitles/testdata.ts
Normal file
68
src/__tests__/subtitles/testdata.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
const srt = `
|
||||||
|
1
|
||||||
|
00:00:00,000 --> 00:00:00,000
|
||||||
|
Test
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:00,000 --> 00:00:00,000
|
||||||
|
Test
|
||||||
|
`;
|
||||||
|
const vtt = `
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.000 --> 00:00:04.000 position:10%,line-left align:left size:35%
|
||||||
|
Where did he go?
|
||||||
|
|
||||||
|
00:00:03.000 --> 00:00:06.500 position:90% align:right size:35%
|
||||||
|
I think he went down this lane.
|
||||||
|
|
||||||
|
00:00:04.000 --> 00:00:06.500 position:45%,line-right align:center size:35%
|
||||||
|
What are you waiting for?
|
||||||
|
`;
|
||||||
|
const ass = `[Script Info]
|
||||||
|
; Generated by Ebby.co
|
||||||
|
Title:
|
||||||
|
Original Script:
|
||||||
|
ScriptType: v4.00+
|
||||||
|
Collisions: Normal
|
||||||
|
PlayResX: 384
|
||||||
|
PlayResY: 288
|
||||||
|
PlayDepth: 0
|
||||||
|
Timer: 100.0
|
||||||
|
WrapStyle: 0
|
||||||
|
|
||||||
|
[v4+ Styles]
|
||||||
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||||
|
Style: Default, Arial, 16, &H00FFFFFF, &H00000000, &H00000000, &H00000000, 0, 0, 0, 0, 100, 100, 0, 0, 1, 1, 0, 2, 15, 15, 15, 0
|
||||||
|
|
||||||
|
[Events]
|
||||||
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||||
|
Dialogue: 0,0:00:10.00,0:00:20.00,Default,,0000,0000,0000,,This is the first subtitle.
|
||||||
|
Dialogue: 0,0:00:30.00,0:00:34.00,Default,,0000,0000,0000,,This is the second.
|
||||||
|
Dialogue: 0,0:00:34.00,0:00:35.00,Default,,0000,0000,0000,,Third`;
|
||||||
|
|
||||||
|
const visibleSubtitlesTestVtt = `WEBVTT
|
||||||
|
|
||||||
|
00:00:00.000 --> 00:00:10.000 position:10%,line-left align:left size:35%
|
||||||
|
Test 1
|
||||||
|
|
||||||
|
00:00:10.000 --> 00:00:20.000 position:90% align:right size:35%
|
||||||
|
Test 2
|
||||||
|
|
||||||
|
00:00:20.000 --> 00:00:31.000 position:45%,line-right align:center size:35%
|
||||||
|
Test 3
|
||||||
|
`;
|
||||||
|
|
||||||
|
const multilineSubtitlesTestVtt = `WEBVTT
|
||||||
|
|
||||||
|
00:00:00.000 --> 00:00:10.000
|
||||||
|
- Test 1\n- Test 2\n- Test 3
|
||||||
|
|
||||||
|
00:00:10.000 --> 00:00:20.000
|
||||||
|
- Test 4
|
||||||
|
|
||||||
|
00:00:20.000 --> 00:00:31.000
|
||||||
|
- Test 6
|
||||||
|
`;
|
||||||
|
|
||||||
|
export { vtt, srt, ass, visibleSubtitlesTestVtt, multilineSubtitlesTestVtt };
|
@ -1,20 +1,33 @@
|
|||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { detect, list, parse } from "subsrt-ts";
|
import { convert, detect, list, parse } from "subsrt-ts";
|
||||||
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||||
|
|
||||||
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
|
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
|
||||||
import { MWCaption } from "@/backend/helpers/streams";
|
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
|
||||||
|
|
||||||
export const customCaption = "external-custom";
|
export const customCaption = "external-custom";
|
||||||
export function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
|
export function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
|
||||||
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
|
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
|
||||||
}
|
}
|
||||||
export const subtitleTypeList = list().map((type) => `.${type}`);
|
export const subtitleTypeList = list().map((type) => `.${type}`);
|
||||||
|
export function isSupportedSubtitle(url: string): boolean {
|
||||||
|
return subtitleTypeList.some((type) => url.endsWith(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMWCaptionTypeFromUrl(url: string): MWCaptionType {
|
||||||
|
if (!isSupportedSubtitle(url)) return MWCaptionType.UNKNOWN;
|
||||||
|
const type = subtitleTypeList.find((t) => url.endsWith(t));
|
||||||
|
if (!type) return MWCaptionType.UNKNOWN;
|
||||||
|
return type.slice(1) as MWCaptionType;
|
||||||
|
}
|
||||||
|
|
||||||
export const sanitize = DOMPurify.sanitize;
|
export const sanitize = DOMPurify.sanitize;
|
||||||
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
||||||
if (caption.url.startsWith("blob:")) return caption.url;
|
|
||||||
let captionBlob: Blob;
|
let captionBlob: Blob;
|
||||||
if (caption.needsProxy) {
|
if (caption.url.startsWith("blob:")) {
|
||||||
|
// custom subtitle
|
||||||
|
captionBlob = await (await fetch(caption.url)).blob();
|
||||||
|
} else if (caption.needsProxy) {
|
||||||
captionBlob = await proxiedFetch<Blob>(caption.url, {
|
captionBlob = await proxiedFetch<Blob>(caption.url, {
|
||||||
responseType: "blob" as any,
|
responseType: "blob" as any,
|
||||||
});
|
});
|
||||||
@ -23,7 +36,10 @@ export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
|||||||
responseType: "blob" as any,
|
responseType: "blob" as any,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return URL.createObjectURL(captionBlob);
|
// convert to vtt for track element source which will be used in PiP mode
|
||||||
|
const text = await captionBlob.text();
|
||||||
|
const vtt = convert(text, "vtt");
|
||||||
|
return URL.createObjectURL(new Blob([vtt], { type: "text/vtt" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function revokeCaptionBlob(url: string | undefined) {
|
export function revokeCaptionBlob(url: string | undefined) {
|
||||||
@ -33,10 +49,14 @@ export function revokeCaptionBlob(url: string | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseSubtitles(text: string): ContentCaption[] {
|
export function parseSubtitles(text: string): ContentCaption[] {
|
||||||
if (detect(text) === "") {
|
const textTrimmed = text.trim();
|
||||||
|
if (textTrimmed === "") {
|
||||||
|
throw new Error("Given text is empty");
|
||||||
|
}
|
||||||
|
if (detect(textTrimmed) === "") {
|
||||||
throw new Error("Invalid subtitle format");
|
throw new Error("Invalid subtitle format");
|
||||||
}
|
}
|
||||||
return parse(text).filter(
|
return parse(textTrimmed).filter(
|
||||||
(cue) => cue.type === "caption"
|
(cue) => cue.type === "caption"
|
||||||
) as ContentCaption[];
|
) as ContentCaption[];
|
||||||
}
|
}
|
||||||
|
@ -3,9 +3,16 @@ export enum MWStreamType {
|
|||||||
HLS = "hls",
|
HLS = "hls",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// subsrt-ts supported types
|
||||||
export enum MWCaptionType {
|
export enum MWCaptionType {
|
||||||
VTT = "vtt",
|
VTT = "vtt",
|
||||||
SRT = "srt",
|
SRT = "srt",
|
||||||
|
LRC = "lrc",
|
||||||
|
SBV = "sbv",
|
||||||
|
SUB = "sub",
|
||||||
|
SSA = "ssa",
|
||||||
|
ASS = "ass",
|
||||||
|
JSON = "json",
|
||||||
UNKNOWN = "unknown",
|
UNKNOWN = "unknown",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { compareTitle } from "@/utils/titleMatch";
|
import { compareTitle } from "@/utils/titleMatch";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getMWCaptionTypeFromUrl,
|
||||||
|
isSupportedSubtitle,
|
||||||
|
} from "../helpers/captions";
|
||||||
import { proxiedFetch } from "../helpers/fetch";
|
import { proxiedFetch } from "../helpers/fetch";
|
||||||
import { registerProvider } from "../helpers/register";
|
import { registerProvider } from "../helpers/register";
|
||||||
import {
|
import { MWCaption, MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||||
MWCaptionType,
|
|
||||||
MWStreamQuality,
|
|
||||||
MWStreamType,
|
|
||||||
} from "../helpers/streams";
|
|
||||||
import { MWMediaType } from "../metadata/types";
|
import { MWMediaType } from "../metadata/types";
|
||||||
|
|
||||||
const flixHqBase = "https://api.consumet.org/meta/tmdb";
|
const flixHqBase = "https://api.consumet.org/meta/tmdb";
|
||||||
@ -20,15 +20,19 @@ interface FLIXMediaBase {
|
|||||||
type: FlixHQMediaType;
|
type: FlixHQMediaType;
|
||||||
releaseDate: string;
|
releaseDate: string;
|
||||||
}
|
}
|
||||||
|
interface FLIXSubType {
|
||||||
function castSubtitles({ url, lang }: { url: string; lang: string }) {
|
url: string;
|
||||||
|
lang: string;
|
||||||
|
}
|
||||||
|
function convertSubtitles({ url, lang }: FLIXSubType): MWCaption | null {
|
||||||
|
if (lang.includes("(maybe)")) return null;
|
||||||
|
const supported = isSupportedSubtitle(url);
|
||||||
|
if (!supported) return null;
|
||||||
|
const type = getMWCaptionTypeFromUrl(url);
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
langIso: lang,
|
langIso: lang,
|
||||||
type:
|
type,
|
||||||
url.substring(url.length - 3) === "vtt"
|
|
||||||
? MWCaptionType.VTT
|
|
||||||
: MWCaptionType.SRT,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,11 +121,7 @@ registerProvider({
|
|||||||
streamUrl: source.url,
|
streamUrl: source.url,
|
||||||
quality: qualityMap[source.quality],
|
quality: qualityMap[source.quality],
|
||||||
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4,
|
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4,
|
||||||
captions: watchInfo.subtitles
|
captions: watchInfo.subtitles.map(convertSubtitles).filter(Boolean),
|
||||||
.filter(
|
|
||||||
(x: { url: string; lang: string }) => !x.lang.includes("(maybe)")
|
|
||||||
)
|
|
||||||
.map(castSubtitles),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
import CryptoJS from "crypto-js";
|
import CryptoJS from "crypto-js";
|
||||||
import { customAlphabet } from "nanoid";
|
import { customAlphabet } from "nanoid";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getMWCaptionTypeFromUrl,
|
||||||
|
isSupportedSubtitle,
|
||||||
|
} from "@/backend/helpers/captions";
|
||||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||||
import { registerProvider } from "@/backend/helpers/register";
|
import { registerProvider } from "@/backend/helpers/register";
|
||||||
import {
|
import {
|
||||||
@ -111,6 +115,30 @@ const getBestQuality = (list: any[]) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const convertSubtitles = (subtitleGroup: any): MWCaption | null => {
|
||||||
|
let subtitles = subtitleGroup.subtitles;
|
||||||
|
subtitles = subtitles
|
||||||
|
.map((subFile: any) => {
|
||||||
|
const supported = isSupportedSubtitle(subFile.file_path);
|
||||||
|
if (!supported) return null;
|
||||||
|
const type = getMWCaptionTypeFromUrl(subFile.file_path);
|
||||||
|
return {
|
||||||
|
...subFile,
|
||||||
|
type: type as MWCaptionType,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (subtitles.length === 0) return null;
|
||||||
|
const subFile = subtitles[0];
|
||||||
|
return {
|
||||||
|
needsProxy: true,
|
||||||
|
langIso: subtitleGroup.language,
|
||||||
|
url: subFile.file_path,
|
||||||
|
type: subFile.type,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
registerProvider({
|
registerProvider({
|
||||||
id: "superstream",
|
id: "superstream",
|
||||||
displayName: "Superstream",
|
displayName: "Superstream",
|
||||||
@ -164,16 +192,9 @@ registerProvider({
|
|||||||
|
|
||||||
const subtitleRes = (await get(subtitleApiQuery)).data;
|
const subtitleRes = (await get(subtitleApiQuery)).data;
|
||||||
|
|
||||||
const mappedCaptions = subtitleRes.list.map(
|
const mappedCaptions = subtitleRes.list
|
||||||
(subtitle: any): MWCaption => {
|
.map(convertSubtitles)
|
||||||
return {
|
.filter(Boolean);
|
||||||
needsProxy: true,
|
|
||||||
langIso: subtitle.language,
|
|
||||||
url: subtitle.subtitles[0].file_path,
|
|
||||||
type: MWCaptionType.SRT,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
embeds: [],
|
embeds: [],
|
||||||
@ -224,22 +245,9 @@ registerProvider({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const subtitleRes = (await get(subtitleApiQuery)).data;
|
const subtitleRes = (await get(subtitleApiQuery)).data;
|
||||||
|
const mappedCaptions = subtitleRes.list
|
||||||
const mappedCaptions = subtitleRes.list.map(
|
.map(convertSubtitles)
|
||||||
(subtitle: any): MWCaption | null => {
|
.filter(Boolean);
|
||||||
const sub = subtitle;
|
|
||||||
sub.subtitles = subtitle.subtitles.filter((subFile: any) => {
|
|
||||||
const extension = subFile.file_path.slice(-3);
|
|
||||||
return [MWCaptionType.SRT, MWCaptionType.VTT].includes(extension);
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
needsProxy: true,
|
|
||||||
langIso: subtitle.language,
|
|
||||||
url: sub.subtitles[0].file_path,
|
|
||||||
type: MWCaptionType.SRT,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
embeds: [],
|
embeds: [],
|
||||||
stream: {
|
stream: {
|
||||||
|
@ -37,7 +37,7 @@ export function Dropdown(props: DropdownProps) {
|
|||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<Listbox.Options className="absolute top-10 left-0 right-0 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:top-10 sm:text-sm">
|
<Listbox.Options className="absolute left-0 right-0 top-10 z-10 mt-1 max-h-60 overflow-auto rounded-md bg-denim-500 py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:top-10 sm:text-sm">
|
||||||
{props.options.map((opt) => (
|
{props.options.map((opt) => (
|
||||||
<Listbox.Option
|
<Listbox.Option
|
||||||
className={({ active }) =>
|
className={({ active }) =>
|
||||||
|
@ -40,7 +40,7 @@ export function SearchBarInput(props: SearchBarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col rounded-[28px] bg-denim-400 transition-colors focus-within:bg-denim-400 hover:bg-denim-500 sm:flex-row sm:items-center">
|
<div className="relative flex flex-col rounded-[28px] bg-denim-400 transition-colors focus-within:bg-denim-400 hover:bg-denim-500 sm:flex-row sm:items-center">
|
||||||
<div className="pointer-events-none absolute left-5 top-0 bottom-0 flex max-h-14 items-center">
|
<div className="pointer-events-none absolute bottom-0 left-5 top-0 flex max-h-14 items-center">
|
||||||
<Icon icon={Icons.SEARCH} />
|
<Icon icon={Icons.SEARCH} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -52,7 +52,7 @@ export function SearchBarInput(props: SearchBarProps) {
|
|||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="px-4 py-4 pt-0 sm:py-2 sm:px-2">
|
<div className="px-4 py-4 pt-0 sm:px-2 sm:py-2">
|
||||||
<DropdownButton
|
<DropdownButton
|
||||||
icon={Icons.SEARCH}
|
icon={Icons.SEARCH}
|
||||||
open={dropdownOpen}
|
open={dropdownOpen}
|
||||||
|
@ -100,7 +100,7 @@ export function BackdropContainer(
|
|||||||
return (
|
return (
|
||||||
<div ref={root}>
|
<div ref={root}>
|
||||||
{createPortal(
|
{createPortal(
|
||||||
<div className="pointer-events-none fixed top-0 left-0 z-[999]">
|
<div className="pointer-events-none fixed left-0 top-0 z-[999]">
|
||||||
<Backdrop active={props.active} {...props} />
|
<Backdrop active={props.active} {...props} />
|
||||||
<div ref={copy} className="pointer-events-auto absolute">
|
<div ref={copy} className="pointer-events-auto absolute">
|
||||||
{props.children}
|
{props.children}
|
||||||
|
@ -24,7 +24,7 @@ export function Navigation(props: NavigationProps) {
|
|||||||
top: `${bannerHeight}px`,
|
top: `${bannerHeight}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="fixed left-0 right-0 flex items-center justify-between py-5 px-7">
|
<div className="fixed left-0 right-0 flex items-center justify-between px-7 py-5">
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
props.bg ? "opacity-100" : "opacity-0"
|
props.bg ? "opacity-100" : "opacity-0"
|
||||||
|
@ -9,12 +9,12 @@ export function Episode(props: EpisodeProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
className={`transition-[background-color, transform, box-shadow] relative mr-3 mb-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded bg-denim-500 font-bold text-white hover:bg-denim-400 active:scale-110 ${
|
className={`transition-[background-color, transform, box-shadow] relative mb-3 mr-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded bg-denim-500 font-bold text-white hover:bg-denim-400 active:scale-110 ${
|
||||||
props.active ? "shadow-[inset_0_0_0_2px] shadow-bink-500" : ""
|
props.active ? "shadow-[inset_0_0_0_2px] shadow-bink-500" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-0 top-0 left-0 bg-bink-500 bg-opacity-50"
|
className="absolute bottom-0 left-0 top-0 bg-bink-500 bg-opacity-50"
|
||||||
style={{
|
style={{
|
||||||
width: `${props.progress || 0}%`,
|
width: `${props.progress || 0}%`,
|
||||||
}}
|
}}
|
||||||
|
@ -61,7 +61,7 @@ function MediaCardContent({
|
|||||||
{series ? (
|
{series ? (
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
"absolute right-2 top-2 rounded-md bg-denim-200 py-1 px-2 transition-colors",
|
"absolute right-2 top-2 rounded-md bg-denim-200 px-2 py-1 transition-colors",
|
||||||
closable ? "" : "group-hover:bg-denim-500",
|
closable ? "" : "group-hover:bg-denim-500",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
|
@ -167,7 +167,7 @@ export const FloatingCardView = {
|
|||||||
<div>{props.action ?? null}</div>
|
<div>{props.action ?? null}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="mt-8 mb-2 text-3xl font-bold text-white">
|
<h2 className="mb-2 mt-8 text-3xl font-bold text-white">
|
||||||
{props.title}
|
{props.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p>{props.description}</p>
|
<p>{props.description}</p>
|
||||||
|
@ -120,7 +120,7 @@ export function VideoPlayer(props: Props) {
|
|||||||
<Transition
|
<Transition
|
||||||
animation="slide-down"
|
animation="slide-down"
|
||||||
show={show}
|
show={show}
|
||||||
className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2"
|
className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col px-8 py-6 pb-2"
|
||||||
>
|
>
|
||||||
<HeaderAction
|
<HeaderAction
|
||||||
showControls={isMobile}
|
showControls={isMobile}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { useAsync } from "react-use";
|
import { useAsync } from "react-use";
|
||||||
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
||||||
|
|
||||||
@ -50,9 +50,14 @@ export function CaptionRendererAction({
|
|||||||
const descriptor = useVideoPlayerDescriptor();
|
const descriptor = useVideoPlayerDescriptor();
|
||||||
const source = useSource(descriptor).source;
|
const source = useSource(descriptor).source;
|
||||||
const videoTime = useProgress(descriptor).time;
|
const videoTime = useProgress(descriptor).time;
|
||||||
const { captionSettings } = useSettings();
|
const { captionSettings, setCaptionDelay } = useSettings();
|
||||||
const captions = useRef<ContentCaption[]>([]);
|
const captions = useRef<ContentCaption[]>([]);
|
||||||
|
|
||||||
|
const captionSetRef = useRef<(delay: number) => void>(setCaptionDelay);
|
||||||
|
useEffect(() => {
|
||||||
|
captionSetRef.current = setCaptionDelay;
|
||||||
|
}, [setCaptionDelay]);
|
||||||
|
|
||||||
useAsync(async () => {
|
useAsync(async () => {
|
||||||
const blobUrl = source?.caption?.url;
|
const blobUrl = source?.caption?.url;
|
||||||
if (blobUrl) {
|
if (blobUrl) {
|
||||||
@ -63,20 +68,38 @@ export function CaptionRendererAction({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
captions.current = [];
|
captions.current = [];
|
||||||
}
|
}
|
||||||
|
// reset delay on every subtitle change
|
||||||
|
setCaptionDelay(0);
|
||||||
} else {
|
} else {
|
||||||
captions.current = [];
|
captions.current = [];
|
||||||
}
|
}
|
||||||
}, [source?.caption?.url]);
|
}, [source?.caption?.url]);
|
||||||
|
|
||||||
if (!captions.current.length) return null;
|
// reset delay when loading new source url
|
||||||
const isVisible = (start: number, end: number): boolean => {
|
useEffect(() => {
|
||||||
const delayedStart = start / 1000 + captionSettings.delay;
|
captionSetRef.current(0);
|
||||||
const delayedEnd = end / 1000 + captionSettings.delay;
|
}, [source?.caption?.url]);
|
||||||
|
|
||||||
|
const isVisible = useCallback(
|
||||||
|
(
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
delay: number,
|
||||||
|
currentTime: number
|
||||||
|
): boolean => {
|
||||||
|
const delayedStart = start / 1000 + delay;
|
||||||
|
const delayedEnd = end / 1000 + delay;
|
||||||
return (
|
return (
|
||||||
Math.max(0, delayedStart) <= videoTime &&
|
Math.max(0, delayedStart) <= currentTime &&
|
||||||
Math.max(0, delayedEnd) >= videoTime
|
Math.max(0, delayedEnd) >= currentTime
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
if (!captions.current.length) return null;
|
||||||
|
const visibileCaptions = captions.current.filter(({ start, end }) =>
|
||||||
|
isVisible(start, end, captionSettings.delay, videoTime)
|
||||||
);
|
);
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
className={[
|
className={[
|
||||||
@ -86,12 +109,9 @@ export function CaptionRendererAction({
|
|||||||
animation="slide-up"
|
animation="slide-up"
|
||||||
show
|
show
|
||||||
>
|
>
|
||||||
{captions.current.map(
|
{visibileCaptions.map(({ start, end, content }) => (
|
||||||
({ start, end, content }) =>
|
|
||||||
isVisible(start, end) && (
|
|
||||||
<CaptionCue key={`${start}-${end}`} text={content} />
|
<CaptionCue key={`${start}-${end}`} text={content} />
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</Transition>
|
</Transition>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,7 @@ export function VolumeAdjustedAction() {
|
|||||||
videoInterface.volumeChangedWithKeybind
|
videoInterface.volumeChangedWithKeybind
|
||||||
? "mt-10 scale-100 opacity-100"
|
? "mt-10 scale-100 opacity-100"
|
||||||
: "mt-5 scale-75 opacity-0",
|
: "mt-5 scale-75 opacity-0",
|
||||||
"absolute left-1/2 z-[100] flex -translate-x-1/2 items-center space-x-4 rounded-full bg-bink-300 bg-opacity-50 py-2 px-5 transition-all duration-100",
|
"absolute left-1/2 z-[100] flex -translate-x-1/2 items-center space-x-4 rounded-full bg-bink-300 bg-opacity-50 px-5 py-2 transition-all duration-100",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
|
@ -8,7 +8,7 @@ export function QualityDisplayAction() {
|
|||||||
if (!source.source) return null;
|
if (!source.source) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md bg-denim-300 py-1 px-2 transition-colors">
|
<div className="rounded-md bg-denim-300 px-2 py-1 transition-colors">
|
||||||
<p className="text-center text-xs font-bold text-slate-300 transition-colors">
|
<p className="text-center text-xs font-bold text-slate-300 transition-colors">
|
||||||
{source.source.quality}
|
{source.source.quality}
|
||||||
</p>
|
</p>
|
||||||
|
@ -64,7 +64,7 @@ export class VideoErrorBoundary extends Component<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 bg-denim-100">
|
<div className="absolute inset-0 bg-denim-100">
|
||||||
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2">
|
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col px-8 py-6 pb-2">
|
||||||
<VideoPlayerHeader
|
<VideoPlayerHeader
|
||||||
media={this.props.media}
|
media={this.props.media}
|
||||||
onClick={this.props.onGoBack}
|
onClick={this.props.onGoBack}
|
||||||
|
@ -32,7 +32,7 @@ export function VideoPlayerError(props: VideoPlayerErrorProps) {
|
|||||||
{err?.name}: {err?.description}
|
{err?.name}: {err?.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2">
|
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col px-8 py-6 pb-2">
|
||||||
<VideoPlayerHeader media={meta?.meta.meta} onClick={props.onGoBack} />
|
<VideoPlayerHeader media={meta?.meta.meta} onClick={props.onGoBack} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -51,7 +51,7 @@ export default function VideoTesterView() {
|
|||||||
|
|
||||||
if (video) {
|
if (video) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-0 left-0 h-[100dvh] w-screen">
|
<div className="fixed left-0 top-0 h-[100dvh] w-screen">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<html data-full="true" />
|
<html data-full="true" />
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
@ -14,7 +14,7 @@ export function MediaFetchErrorView() {
|
|||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{t("media.errors.failedMeta")}</title>
|
<title>{t("media.errors.failedMeta")}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<div className="fixed inset-x-0 top-0 py-6 px-8">
|
<div className="fixed inset-x-0 top-0 px-8 py-6">
|
||||||
<VideoPlayerHeader onClick={goBack} />
|
<VideoPlayerHeader onClick={goBack} />
|
||||||
</div>
|
</div>
|
||||||
<ErrorMessage>
|
<ErrorMessage>
|
||||||
|
@ -34,7 +34,7 @@ function MediaViewLoading(props: { onGoBack(): void }) {
|
|||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{t("videoPlayer.loading")}</title>
|
<title>{t("videoPlayer.loading")}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<div className="absolute inset-x-0 top-0 py-6 px-8">
|
<div className="absolute inset-x-0 top-0 px-8 py-6">
|
||||||
<VideoPlayerHeader onClick={props.onGoBack} />
|
<VideoPlayerHeader onClick={props.onGoBack} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
@ -68,7 +68,7 @@ function MediaViewScraping(props: MediaViewScrapingProps) {
|
|||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{props.meta.meta.title}</title>
|
<title>{props.meta.meta.title}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<div className="absolute inset-x-0 top-0 py-6 px-8">
|
<div className="absolute inset-x-0 top-0 px-8 py-6">
|
||||||
<VideoPlayerHeader onClick={props.onGoBack} media={props.meta.meta} />
|
<VideoPlayerHeader onClick={props.onGoBack} media={props.meta.meta} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center transition-opacity duration-200">
|
<div className="flex flex-col items-center transition-opacity duration-200">
|
||||||
@ -134,7 +134,7 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-0 left-0 h-[100dvh] w-screen">
|
<div className="fixed left-0 top-0 h-[100dvh] w-screen">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<html data-full="true" />
|
<html data-full="true" />
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
@ -23,7 +23,7 @@ export function NotFoundWrapper(props: {
|
|||||||
<title>{t("notFound.genericTitle")}</title>
|
<title>{t("notFound.genericTitle")}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
{props.video ? (
|
{props.video ? (
|
||||||
<div className="absolute inset-x-0 top-0 py-6 px-8">
|
<div className="absolute inset-x-0 top-0 px-8 py-6">
|
||||||
<VideoPlayerHeader onClick={goBack} />
|
<VideoPlayerHeader onClick={goBack} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -46,7 +46,7 @@ export function NotFoundMedia() {
|
|||||||
className="mb-6 text-xl text-bink-600"
|
className="mb-6 text-xl text-bink-600"
|
||||||
/>
|
/>
|
||||||
<Title>{t("notFound.media.title")}</Title>
|
<Title>{t("notFound.media.title")}</Title>
|
||||||
<p className="mt-5 mb-12 max-w-sm">{t("notFound.media.description")}</p>
|
<p className="mb-12 mt-5 max-w-sm">{t("notFound.media.description")}</p>
|
||||||
<ArrowLink to="/" linkText={t("notFound.backArrow")} />
|
<ArrowLink to="/" linkText={t("notFound.backArrow")} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -62,7 +62,7 @@ export function NotFoundProvider() {
|
|||||||
className="mb-6 text-xl text-bink-600"
|
className="mb-6 text-xl text-bink-600"
|
||||||
/>
|
/>
|
||||||
<Title>{t("notFound.provider.title")}</Title>
|
<Title>{t("notFound.provider.title")}</Title>
|
||||||
<p className="mt-5 mb-12 max-w-sm">
|
<p className="mb-12 mt-5 max-w-sm">
|
||||||
{t("notFound.provider.description")}
|
{t("notFound.provider.description")}
|
||||||
</p>
|
</p>
|
||||||
<ArrowLink to="/" linkText={t("notFound.backArrow")} />
|
<ArrowLink to="/" linkText={t("notFound.backArrow")} />
|
||||||
@ -80,7 +80,7 @@ export function NotFoundPage() {
|
|||||||
className="mb-6 text-xl text-bink-600"
|
className="mb-6 text-xl text-bink-600"
|
||||||
/>
|
/>
|
||||||
<Title>{t("notFound.page.title")}</Title>
|
<Title>{t("notFound.page.title")}</Title>
|
||||||
<p className="mt-5 mb-12 max-w-sm">{t("notFound.page.description")}</p>
|
<p className="mb-12 mt-5 max-w-sm">{t("notFound.page.description")}</p>
|
||||||
<ArrowLink to="/" linkText={t("notFound.backArrow")} />
|
<ArrowLink to="/" linkText={t("notFound.backArrow")} />
|
||||||
</NotFoundWrapper>
|
</NotFoundWrapper>
|
||||||
);
|
);
|
||||||
|
@ -169,7 +169,7 @@ function NewDomainModal() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex items-center justify-center">
|
<div className="relative flex items-center justify-center">
|
||||||
<div className="rounded-full bg-bink-200 py-4 px-12 text-center text-sm font-bold text-white md:text-xl">
|
<div className="rounded-full bg-bink-200 px-12 py-4 text-center text-sm font-bold text-white md:text-xl">
|
||||||
{t("v3.newDomain")}
|
{t("v3.newDomain")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -186,7 +186,7 @@ function NewDomainModal() {
|
|||||||
</p>
|
</p>
|
||||||
<p>{t("v3.tireless")}</p>
|
<p>{t("v3.tireless")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-16 mb-6 flex items-center justify-center">
|
<div className="mb-6 mt-16 flex items-center justify-center">
|
||||||
<Button icon={Icons.PLAY} onClick={() => closeModal()}>
|
<Button icon={Icons.PLAY} onClick={() => closeModal()}>
|
||||||
{t("v3.leaveAnnouncement")}
|
{t("v3.leaveAnnouncement")}
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -8,7 +8,7 @@ export function SearchLoadingView() {
|
|||||||
const [query] = useSearchQuery();
|
const [query] = useSearchQuery();
|
||||||
return (
|
return (
|
||||||
<Loading
|
<Loading
|
||||||
className="mt-40 mb-24 "
|
className="mb-24 mt-40 "
|
||||||
text={
|
text={
|
||||||
t(`search.loading_${query.type}`) ||
|
t(`search.loading_${query.type}`) ||
|
||||||
t("search.loading") ||
|
t("search.loading") ||
|
||||||
|
@ -18,7 +18,7 @@ function SearchSuffix(props: { failed?: boolean; results?: number }) {
|
|||||||
const icon: Icons = props.failed ? Icons.WARNING : Icons.EYE_SLASH;
|
const icon: Icons = props.failed ? Icons.WARNING : Icons.EYE_SLASH;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-40 mb-24 flex flex-col items-center justify-center space-y-3 text-center">
|
<div className="mb-24 mt-40 flex flex-col items-center justify-center space-y-3 text-center">
|
||||||
<IconPatch
|
<IconPatch
|
||||||
icon={icon}
|
icon={icon}
|
||||||
className={`text-xl ${props.failed ? "text-red-400" : "text-bink-600"}`}
|
className={`text-xl ${props.failed ? "text-red-400" : "text-bink-600"}`}
|
||||||
|
@ -33,7 +33,7 @@ export function SearchView() {
|
|||||||
<Navigation bg={showBg} />
|
<Navigation bg={showBg} />
|
||||||
<ThinContainer>
|
<ThinContainer>
|
||||||
<div className="mt-44 space-y-16 text-center">
|
<div className="mt-44 space-y-16 text-center">
|
||||||
<div className="absolute left-0 bottom-0 right-0 flex h-0 justify-center">
|
<div className="absolute bottom-0 left-0 right-0 flex h-0 justify-center">
|
||||||
<div className="absolute bottom-4 h-[100vh] w-[3000px] rounded-[100%] bg-denim-300 md:w-[200vw]" />
|
<div className="absolute bottom-4 h-[100vh] w-[3000px] rounded-[100%] bg-denim-300 md:w-[200vw]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-10 mb-16">
|
<div className="relative z-10 mb-16">
|
||||||
|
Loading…
Reference in New Issue
Block a user