diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..90482e50 --- /dev/null +++ b/biome.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.7.3/schema.json", + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "tab", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 100, + "attributePosition": "auto", + "ignore": ["**/.yarn/**", "**/.next/**", "**/.expo/**", "**/next-env.d.ts", "**/back/**"] + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noNonNullAssertion": "off", + "noParameterAssign": "off", + "useEnumInitializers": "off" + }, + "suspicious": { + "noExplicitAny": "off", + "noArrayIndexKey": "off" + }, + "security": { + "noDangerouslySetInnerHtml": "off" + }, + "complexity": { + "noBannedTypes": "off" + } + }, + "ignore": ["**/.yarn/**", "**/.next/**", "**/.expo/**", "**/next-env.d.ts", "**/back/**"] + }, + "javascript": { + "formatter": { + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "trailingComma": "all", + "semicolons": "always", + "arrowParentheses": "always", + "bracketSpacing": true, + "bracketSameLine": false, + "quoteStyle": "double", + "attributePosition": "auto" + } + } +} diff --git a/front/apps/web/next.config.js b/front/apps/web/next.config.js index a97614aa..1b94df99 100755 --- a/front/apps/web/next.config.js +++ b/front/apps/web/next.config.js @@ -29,7 +29,8 @@ const suboctopus = path.resolve(path.dirname(require.resolve("jassub")), "../dis */ const nextConfig = { swcMinify: true, - reactStrictMode: true, + // can't be true since we would run hls cleanup twice and run on race conditions + reactStrictMode: false, output: "standalone", webpack: (config) => { config.plugins = [ diff --git a/front/biome.json b/front/biome.json index 5dda7011..e70b619a 100644 --- a/front/biome.json +++ b/front/biome.json @@ -1,51 +1,3 @@ { - "$schema": "https://biomejs.dev/schemas/1.7.3/schema.json", - "formatter": { - "enabled": true, - "formatWithErrors": false, - "indentStyle": "tab", - "indentWidth": 2, - "lineEnding": "lf", - "lineWidth": 100, - "attributePosition": "auto", - "ignore": ["**/.yarn/**", "**/.next/**", "**/.expo/**", "**/next-env.d.ts"] - }, - "organizeImports": { - "enabled": true - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "style": { - "noNonNullAssertion": "off", - "noParameterAssign": "off", - "useEnumInitializers": "off" - }, - "suspicious": { - "noExplicitAny": "off", - "noArrayIndexKey": "off" - }, - "security": { - "noDangerouslySetInnerHtml": "off" - }, - "complexity": { - "noBannedTypes": "off" - } - }, - "ignore": ["**/.yarn/**", "**/.next/**", "**/.expo/**", "**/next-env.d.ts"] - }, - "javascript": { - "formatter": { - "jsxQuoteStyle": "double", - "quoteProperties": "asNeeded", - "trailingComma": "all", - "semicolons": "always", - "arrowParentheses": "always", - "bracketSpacing": true, - "bracketSameLine": false, - "quoteStyle": "double", - "attributePosition": "auto" - } - } + "extends": ["../biome.json"] } diff --git a/front/packages/models/src/resources/watch-info.ts b/front/packages/models/src/resources/watch-info.ts index 1fd8ce09..82e624bd 100644 --- a/front/packages/models/src/resources/watch-info.ts +++ b/front/packages/models/src/resources/watch-info.ts @@ -149,6 +149,11 @@ export const WatchInfoP = z * The extension used to store this video file. */ extension: z.string(), + /** + * The whole mimetype (defined as the RFC 6381). + * ex: `video/mp4; codecs="avc1.640028, mp4a.40.2"` + */ + mimeCodec: z.string(), /** * The file size of the video file. */ diff --git a/front/packages/ui/src/player/index.tsx b/front/packages/ui/src/player/index.tsx index 54a1eb42..bd496e29 100644 --- a/front/packages/ui/src/player/index.tsx +++ b/front/packages/ui/src/player/index.tsx @@ -153,6 +153,7 @@ export const Player = ({ links={data?.links} audios={info?.audios} subtitles={info?.subtitles} + codec={info?.mimeCodec} setError={setPlaybackError} fonts={info?.fonts} startTime={startTime} diff --git a/front/packages/ui/src/player/state.tsx b/front/packages/ui/src/player/state.tsx index 59394dd2..0c7f388a 100644 --- a/front/packages/ui/src/player/state.tsx +++ b/front/packages/ui/src/player/state.tsx @@ -33,7 +33,7 @@ import { } from "react"; import { useTranslation } from "react-i18next"; import { Platform } from "react-native"; -import NativeVideo, { type VideoProps } from "./video"; +import NativeVideo, { canPlay, type VideoProps } from "./video"; export const playAtom = atom(true); export const loadAtom = atom(false); @@ -100,6 +100,7 @@ export const Video = memo(function Video({ links, subtitles, audios, + codec, setError, fonts, startTime: startTimeP, @@ -108,6 +109,7 @@ export const Video = memo(function Video({ links?: Episode["links"]; subtitles?: Subtitle[]; audios?: Audio[]; + codec?: string; setError: (error: string | undefined) => void; fonts?: string[]; startTime?: number | null; @@ -132,21 +134,31 @@ export const Video = memo(function Video({ }, [publicProgress]); const getProgress = useAtomCallback(useCallback((get) => get(progressAtom), [])); - const oldLinks = useRef(null); - useLayoutEffect(() => { + useEffect(() => { // Reset the state when a new video is loaded. - setSource((mode === PlayMode.Direct ? links?.direct : links?.hls) ?? null); - setLoad(true); - if (oldLinks.current !== links) { - setPrivateProgress(startTime.current ?? 0); - setPublicProgress(startTime.current ?? 0); - } else { - // keep current time when changing between direct and hls. - startTime.current = getProgress(); + + let newMode = getLocalSetting("playmode", "direct") !== "auto" ? PlayMode.Direct : PlayMode.Hls; + // Only allow direct play if the device supports it + if (newMode === PlayMode.Direct && codec && !canPlay(codec)) { + console.log(`Browser can't natively play ${codec}, switching to hls stream.`); + newMode = PlayMode.Hls; } - oldLinks.current = links; + setPlayMode(newMode); + + setSource((newMode === PlayMode.Direct ? links?.direct : links?.hls) ?? null); + setLoad(true); + setPrivateProgress(startTime.current ?? 0); + setPublicProgress(startTime.current ?? 0); setPlay(true); - }, [mode, links, setLoad, setPrivateProgress, setPublicProgress, setPlay, getProgress]); + }, [links, codec, setLoad, setPrivateProgress, setPublicProgress, setPlay, setPlayMode]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: do not change source when links change, this is done above + useEffect(() => { + setSource((mode === PlayMode.Direct ? links?.direct : links?.hls) ?? null); + // keep current time when changing between direct and hls. + startTime.current = getProgress(); + setPlay(true); + }, [mode, getProgress, setPlay]); const account = useAccount(); const defaultSubLanguage = account?.settings.subtitleLanguage; diff --git a/front/packages/ui/src/player/video.tsx b/front/packages/ui/src/player/video.tsx index 3f4d5dbc..eb0d3139 100644 --- a/front/packages/ui/src/player/video.tsx +++ b/front/packages/ui/src/player/video.tsx @@ -129,6 +129,9 @@ const Video = forwardRef(function Video( export default Video; +// mobile should be able to play everything +export const canPlay = (codec: string) => true; + type CustomMenu = ComponentProps>>; export const AudiosMenu = ({ audios, ...props }: CustomMenu & { audios?: Audio[] }) => { const info = useAtomValue(infoAtom); diff --git a/front/packages/ui/src/player/video.web.tsx b/front/packages/ui/src/player/video.web.tsx index 1646ec4c..fd5f90dc 100644 --- a/front/packages/ui/src/player/video.web.tsx +++ b/front/packages/ui/src/player/video.web.tsx @@ -50,7 +50,7 @@ function uuidv4(): string { const client_id = typeof window === "undefined" ? "ssr" : uuidv4(); const initHls = (): Hls => { - if (hls !== null) return hls; + if (hls) hls.destroy(); const loadPolicy: LoadPolicy = { default: { maxTimeToFirstByteMs: Number.POSITIVE_INFINITY, @@ -124,6 +124,8 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function const ref = useRef(null); const oldHls = useRef(null); const { css } = useYoshiki(); + const errorHandler = useRef(onError); + errorHandler.current = onError; useImperativeHandle( forwaredRef, @@ -148,13 +150,11 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function const subtitle = useAtomValue(subtitleAtom); useSubtitle(ref, subtitle, fonts); - // biome-ignore lint/correctness/useExhaustiveDependencies: onError changes should not restart the playback. + // biome-ignore lint/correctness/useExhaustiveDependencies: do not restart on startPosition change useLayoutEffect(() => { if (!ref?.current || !source.uri) return; if (!hls || oldHls.current !== source.hls) { // Reinit the hls player when we change track. - if (hls) hls.destroy(); - hls = null; hls = initHls(); hls.loadSource(source.hls!); oldHls.current = source.hls; @@ -168,13 +168,21 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function hls.on(Hls.Events.ERROR, (_, d) => { if (!d.fatal || !hls?.media) return; console.warn("Hls error", d); - onError?.call(null, { + errorHandler.current?.({ error: { errorString: d.reason ?? d.error?.message ?? "Unknown hls error" }, }); }); } }, [source.uri, source.hls]); + useEffect(() => { + return () => { + console.log("hls cleanup"); + if (hls) hls.destroy(); + hls = null; + }; + }, []); + const mode = useAtomValue(playModeAtom); const audio = useAtomValue(audioAtom); // biome-ignore lint/correctness/useExhaustiveDependencies: also change when the mode change @@ -244,6 +252,16 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function export default Video; +export const canPlay = (codec: string) => { + // most chrome based browser (and safari I think) supports matroska but reports they do not. + // for those browsers, only check the codecs and not the container. + if (navigator.userAgent.search("Firefox") === -1) + codec = codec.replace("video/x-matroska", "video/mp4"); + const videos = document.getElementsByTagName("video"); + const video = videos.item(0) ?? document.createElement("video"); + return !!video.canPlayType(codec); +}; + const useSubtitle = ( player: RefObject, value: Subtitle | null, @@ -348,7 +366,7 @@ export const AudiosMenu = ({ useEffect(() => { if (!hls) return; hls.on(Hls.Events.AUDIO_TRACK_LOADED, rerender); - return () => hls!.off(Hls.Events.AUDIO_TRACK_LOADED, rerender); + return () => hls?.off(Hls.Events.AUDIO_TRACK_LOADED, rerender); }); if (!hls) return ; @@ -373,11 +391,14 @@ export const QualitiesMenu = (props: ComponentProps) => { const [mode, setPlayMode] = useAtom(playModeAtom); const rerender = useForceRerender(); + // biome-ignore lint/correctness/useExhaustiveDependencies: Inculde hls in dependency array useEffect(() => { if (!hls) return; + // Also rerender when hls instance changes + rerender(); hls.on(Hls.Events.LEVEL_SWITCHED, rerender); - return () => hls!.off(Hls.Events.LEVEL_SWITCHED, rerender); - }); + return () => hls?.off(Hls.Events.LEVEL_SWITCHED, rerender); + }, [hls]); const levelName = (label: Level, auto?: boolean): string => { const height = `${label.height}p`; diff --git a/front/packages/ui/src/player/watch-status-observer.tsx b/front/packages/ui/src/player/watch-status-observer.tsx index 08b4d4d8..ce52ce18 100644 --- a/front/packages/ui/src/player/watch-status-observer.tsx +++ b/front/packages/ui/src/player/watch-status-observer.tsx @@ -42,7 +42,8 @@ export const WatchStatusObserver = ({ await queryClient.invalidateQueries({ queryKey: [type === "episode" ? "show" : type, slug] }), }); const mutate = useCallback( - (type: string, slug: string, seconds: number) => + (type: string, slug: string, seconds: number) => { + if (seconds < 0 || duration <= 0) return; _mutate({ method: "POST", path: [type, slug, "watchStatus"], @@ -51,7 +52,8 @@ export const WatchStatusObserver = ({ watchedTime: Math.round(seconds), percent: Math.round((seconds / duration) * 100), }, - }), + }); + }, [_mutate, duration], ); const readProgress = useAtomCallback( diff --git a/front/packages/ui/src/settings/playback.tsx b/front/packages/ui/src/settings/playback.tsx index 1ceb5b74..55450864 100644 --- a/front/packages/ui/src/settings/playback.tsx +++ b/front/packages/ui/src/settings/playback.tsx @@ -55,10 +55,7 @@ export const PlaybackSettings = () => {