Check if the browser can play codecs before trying to direct play (#486)

This commit is contained in:
Zoe Roux 2024-05-12 18:13:28 +02:00 committed by GitHub
commit bbe88382e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 150 additions and 78 deletions

51
biome.json Normal file
View File

@ -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"
}
}
}

View File

@ -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 = [

View File

@ -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"]
}

View File

@ -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.
*/

View File

@ -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}

View File

@ -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<typeof links | null>(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;

View File

@ -129,6 +129,9 @@ const Video = forwardRef<VideoRef, VideoProps>(function Video(
export default Video;
// mobile should be able to play everything
export const canPlay = (codec: string) => true;
type CustomMenu = ComponentProps<typeof Menu<ComponentProps<typeof IconButton>>>;
export const AudiosMenu = ({ audios, ...props }: CustomMenu & { audios?: Audio[] }) => {
const info = useAtomValue(infoAtom);

View File

@ -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<HTMLVideoElement>(null);
const oldHls = useRef<string | null>(null);
const { css } = useYoshiki();
const errorHandler = useRef<typeof onError>(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<HTMLVideoElement>,
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 <Menu {...props} disabled {...tooltip(t("player.notInPristine"))} />;
@ -373,11 +391,14 @@ export const QualitiesMenu = (props: ComponentProps<typeof Menu>) => {
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`;

View File

@ -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(

View File

@ -55,10 +55,7 @@ export const PlaybackSettings = () => {
<Select
label={t("settings.playback.playmode.label")}
value={playMode}
onValueChange={(value) => {
setDefaultPlayMode(value);
setCurrentPlayMode(value === "direct" ? PlayMode.Direct : PlayMode.Hls);
}}
onValueChange={(value) => setDefaultPlayMode(value)}
values={["direct", "auto"]}
getLabel={(key) => t(`player.${key}`)}
/>

View File

@ -3,6 +3,7 @@ package src
import (
"cmp"
"fmt"
"log"
"strings"
"github.com/zoriya/go-mediainfo"
@ -118,11 +119,16 @@ func GetMimeCodec(mi *mediainfo.File, kind mediainfo.StreamKind, i int) *string
ret := "mp4a.a5"
return &ret
case "audio/eac3", "E-AC-3":
ret := "mp4a.a6"
return &ret
case "audio/x-flac", "FLAC":
ret := "fLaC"
return &ret
default:
log.Printf("No known mime format for: %s", codec)
return nil
}
}

View File

@ -7,6 +7,7 @@ import (
"fmt"
"io"
"log"
"mime"
"os"
"path/filepath"
"strconv"
@ -23,6 +24,8 @@ type MediaInfo struct {
Path string `json:"path"`
/// The extension currently used to store this video file
Extension string `json:"extension"`
/// The whole mimetype (defined as the RFC 6381). ex: `video/mp4; codecs="avc1.640028, mp4a.40.2"`
MimeCodec *string `json:"mimeCodec"`
/// The file size of the video file.
Size uint64 `json:"size"`
/// The length of the media in seconds.
@ -311,6 +314,24 @@ func getInfo(path string) (*MediaInfo, error) {
return fmt.Sprintf("%s/%s/attachment/%s", Settings.RoutePrefix, base64.StdEncoding.EncodeToString([]byte(path)), font)
}),
}
var codecs []string
if len(ret.Videos) > 0 && ret.Videos[0].MimeCodec != nil {
codecs = append(codecs, *ret.Videos[0].MimeCodec)
}
if len(ret.Audios) > 0 && ret.Audios[0].MimeCodec != nil {
codecs = append(codecs, *ret.Audios[0].MimeCodec)
}
container := mime.TypeByExtension(fmt.Sprintf(".%s", ret.Extension))
if container != "" {
if len(codecs) > 0 {
codecs_str := strings.Join(codecs, ", ")
mime := fmt.Sprintf("%s; codecs=\"%s\"", container, codecs_str)
ret.MimeCodec = &mime
} else {
ret.MimeCodec = &container
}
}
if len(ret.Videos) > 0 {
ret.Video = &ret.Videos[0]
}

View File

@ -19,7 +19,7 @@ var safe_path = src.GetEnvOr("GOCODER_SAFE_PATH", "/video")
// Encode the version in the hash path to update cached values.
// Older versions won't be deleted (needed to allow multiples versions of the transcoder to run at the same time)
// If the version changes a lot, we might want to automatically delete older versions.
var version = "v1."
var version = "v2-"
func GetPath(c echo.Context) (string, string, error) {
key := c.Param("path")