mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-27 10:11:48 +01:00
Merge pull request #1038 from movie-web/feature/audiotracks
Support for HLS audio tracks
This commit is contained in:
commit
94c4e71756
@ -294,6 +294,7 @@
|
|||||||
"enableSubtitles": "Enable Subtitles",
|
"enableSubtitles": "Enable Subtitles",
|
||||||
"experienceSection": "Viewing experience",
|
"experienceSection": "Viewing experience",
|
||||||
"playbackItem": "Playback settings",
|
"playbackItem": "Playback settings",
|
||||||
|
"audioItem": "Audio",
|
||||||
"qualityItem": "Quality",
|
"qualityItem": "Quality",
|
||||||
"sourceItem": "Video sources",
|
"sourceItem": "Video sources",
|
||||||
"subtitleItem": "Subtitle settings",
|
"subtitleItem": "Subtitle settings",
|
||||||
|
@ -14,6 +14,7 @@ import { Menu } from "@/components/player/internals/ContextMenu";
|
|||||||
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||||
import { usePlayerStore } from "@/stores/player/store";
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
|
||||||
|
import { AudioView } from "./settings/AudioView";
|
||||||
import { CaptionSettingsView } from "./settings/CaptionSettingsView";
|
import { CaptionSettingsView } from "./settings/CaptionSettingsView";
|
||||||
import { CaptionsView } from "./settings/CaptionsView";
|
import { CaptionsView } from "./settings/CaptionsView";
|
||||||
import { DownloadRoutes } from "./settings/Downloads";
|
import { DownloadRoutes } from "./settings/Downloads";
|
||||||
@ -46,6 +47,11 @@ function SettingsOverlay({ id }: { id: string }) {
|
|||||||
<QualityView id={id} />
|
<QualityView id={id} />
|
||||||
</Menu.Card>
|
</Menu.Card>
|
||||||
</OverlayPage>
|
</OverlayPage>
|
||||||
|
<OverlayPage id={id} path="/audio" width={343} height={431}>
|
||||||
|
<Menu.Card>
|
||||||
|
<AudioView id={id} />
|
||||||
|
</Menu.Card>
|
||||||
|
</OverlayPage>
|
||||||
<OverlayPage id={id} path="/captions" width={343} height={431}>
|
<OverlayPage id={id} path="/captions" width={343} height={431}>
|
||||||
<Menu.CardWithScrollable>
|
<Menu.CardWithScrollable>
|
||||||
<CaptionsView id={id} />
|
<CaptionsView id={id} />
|
||||||
|
65
src/components/player/atoms/settings/AudioView.tsx
Normal file
65
src/components/player/atoms/settings/AudioView.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { FlagIcon } from "@/components/FlagIcon";
|
||||||
|
import { Menu } from "@/components/player/internals/ContextMenu";
|
||||||
|
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||||
|
import { AudioTrack } from "@/stores/player/slices/source";
|
||||||
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
import { getPrettyLanguageNameFromLocale } from "@/utils/language";
|
||||||
|
|
||||||
|
import { SelectableLink } from "../../internals/ContextMenu/Links";
|
||||||
|
|
||||||
|
export function AudioOption(props: {
|
||||||
|
langCode?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
selected?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectableLink selected={props.selected} onClick={props.onClick}>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<span data-code={props.langCode} className="mr-3 inline-flex">
|
||||||
|
<FlagIcon langCode={props.langCode} />
|
||||||
|
</span>
|
||||||
|
<span>{props.children}</span>
|
||||||
|
</span>
|
||||||
|
</SelectableLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioView({ id }: { id: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const unknownChoice = t("player.menus.subtitles.unknownLanguage");
|
||||||
|
|
||||||
|
const router = useOverlayRouter(id);
|
||||||
|
const audioTracks = usePlayerStore((s) => s.audioTracks);
|
||||||
|
const currentAudioTrack = usePlayerStore((s) => s.currentAudioTrack);
|
||||||
|
const changeAudioTrack = usePlayerStore((s) => s.display?.changeAudioTrack);
|
||||||
|
|
||||||
|
const change = useCallback(
|
||||||
|
(track: AudioTrack) => {
|
||||||
|
changeAudioTrack?.(track);
|
||||||
|
router.close();
|
||||||
|
},
|
||||||
|
[router, changeAudioTrack],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Menu.BackLink onClick={() => router.navigate("/")}>Audio</Menu.BackLink>
|
||||||
|
<Menu.Section className="flex flex-col pb-4">
|
||||||
|
{audioTracks.map((v) => (
|
||||||
|
<AudioOption
|
||||||
|
key={v.id}
|
||||||
|
selected={v.id === currentAudioTrack?.id}
|
||||||
|
langCode={v.language}
|
||||||
|
onClick={audioTracks.includes(v) ? () => change(v) : undefined}
|
||||||
|
>
|
||||||
|
{getPrettyLanguageNameFromLocale(v.language) ?? unknownChoice}
|
||||||
|
</AudioOption>
|
||||||
|
))}
|
||||||
|
</Menu.Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -16,6 +16,7 @@ export function SettingsMenu({ id }: { id: string }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useOverlayRouter(id);
|
const router = useOverlayRouter(id);
|
||||||
const currentQuality = usePlayerStore((s) => s.currentQuality);
|
const currentQuality = usePlayerStore((s) => s.currentQuality);
|
||||||
|
const currentAudioTrack = usePlayerStore((s) => s.currentAudioTrack);
|
||||||
const selectedCaptionLanguage = usePlayerStore(
|
const selectedCaptionLanguage = usePlayerStore(
|
||||||
(s) => s.caption.selected?.language,
|
(s) => s.caption.selected?.language,
|
||||||
);
|
);
|
||||||
@ -35,6 +36,11 @@ export function SettingsMenu({ id }: { id: string }) {
|
|||||||
t("player.menus.subtitles.unknownLanguage")
|
t("player.menus.subtitles.unknownLanguage")
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const selectedAudioLanguagePretty = currentAudioTrack
|
||||||
|
? getPrettyLanguageNameFromLocale(currentAudioTrack.language) ??
|
||||||
|
t("player.menus.subtitles.unknownLanguage")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const source = usePlayerStore((s) => s.source);
|
const source = usePlayerStore((s) => s.source);
|
||||||
|
|
||||||
const downloadable = source?.type === "file" || source?.type === "hls";
|
const downloadable = source?.type === "file" || source?.type === "hls";
|
||||||
@ -51,6 +57,15 @@ export function SettingsMenu({ id }: { id: string }) {
|
|||||||
>
|
>
|
||||||
{t("player.menus.settings.qualityItem")}
|
{t("player.menus.settings.qualityItem")}
|
||||||
</Menu.ChevronLink>
|
</Menu.ChevronLink>
|
||||||
|
{currentAudioTrack && (
|
||||||
|
<Menu.ChevronLink
|
||||||
|
onClick={() => router.navigate("/audio")}
|
||||||
|
rightText={selectedAudioLanguagePretty ?? undefined}
|
||||||
|
>
|
||||||
|
{t("player.menus.settings.audioItem")}
|
||||||
|
</Menu.ChevronLink>
|
||||||
|
)}
|
||||||
|
|
||||||
<Menu.ChevronLink
|
<Menu.ChevronLink
|
||||||
onClick={() => router.navigate("/source")}
|
onClick={() => router.navigate("/source")}
|
||||||
rightText={sourceName}
|
rightText={sourceName}
|
||||||
|
@ -81,6 +81,24 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
|||||||
emit("qualities", convertedLevels);
|
emit("qualities", convertedLevels);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reportAudioTracks() {
|
||||||
|
if (!hls) return;
|
||||||
|
const currentTrack = hls.audioTracks[hls.audioTrack];
|
||||||
|
emit("changedaudiotrack", {
|
||||||
|
id: currentTrack.id.toString(),
|
||||||
|
label: currentTrack.name,
|
||||||
|
language: currentTrack.lang ?? "unknown",
|
||||||
|
});
|
||||||
|
emit(
|
||||||
|
"audiotracks",
|
||||||
|
hls.audioTracks.map((v) => ({
|
||||||
|
id: v.id.toString(),
|
||||||
|
label: v.name,
|
||||||
|
language: v.lang ?? "unknown",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function setupQualityForHls() {
|
function setupQualityForHls() {
|
||||||
if (videoElement && canPlayHlsNatively(videoElement)) {
|
if (videoElement && canPlayHlsNatively(videoElement)) {
|
||||||
return; // nothing to change
|
return; // nothing to change
|
||||||
@ -155,6 +173,7 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
|||||||
if (!hls) return;
|
if (!hls) return;
|
||||||
reportLevels();
|
reportLevels();
|
||||||
setupQualityForHls();
|
setupQualityForHls();
|
||||||
|
reportAudioTracks();
|
||||||
|
|
||||||
if (isExtensionActiveCached()) {
|
if (isExtensionActiveCached()) {
|
||||||
hls.on(Hls.Events.LEVEL_LOADED, async (_, data) => {
|
hls.on(Hls.Events.LEVEL_LOADED, async (_, data) => {
|
||||||
@ -464,5 +483,18 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
|||||||
hls?.setSubtitleOption({ lang });
|
hls?.setSubtitleOption({ lang });
|
||||||
return promise;
|
return promise;
|
||||||
},
|
},
|
||||||
|
changeAudioTrack(track) {
|
||||||
|
if (!hls) return;
|
||||||
|
const audioTrack = hls?.audioTracks.find(
|
||||||
|
(t) => t.id.toString() === track.id,
|
||||||
|
);
|
||||||
|
if (!audioTrack) return;
|
||||||
|
hls.audioTrack = hls.audioTracks.indexOf(audioTrack);
|
||||||
|
emit("changedaudiotrack", {
|
||||||
|
id: audioTrack.id.toString(),
|
||||||
|
label: audioTrack.name,
|
||||||
|
language: audioTrack.lang ?? "unknown",
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -283,5 +283,8 @@ export function makeChromecastDisplayInterface(
|
|||||||
async setSubtitlePreference() {
|
async setSubtitlePreference() {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
|
changeAudioTrack() {
|
||||||
|
// cant change audio tracks
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { MediaPlaylist } from "hls.js";
|
import { MediaPlaylist } from "hls.js";
|
||||||
|
|
||||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||||
import { CaptionListItem } from "@/stores/player/slices/source";
|
import { AudioTrack, CaptionListItem } from "@/stores/player/slices/source";
|
||||||
import { LoadableSource, SourceQuality } from "@/stores/player/utils/qualities";
|
import { LoadableSource, SourceQuality } from "@/stores/player/utils/qualities";
|
||||||
import { Listener } from "@/utils/events";
|
import { Listener } from "@/utils/events";
|
||||||
|
|
||||||
@ -25,6 +25,8 @@ export type DisplayInterfaceEvents = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
qualities: SourceQuality[];
|
qualities: SourceQuality[];
|
||||||
changedquality: SourceQuality | null;
|
changedquality: SourceQuality | null;
|
||||||
|
audiotracks: AudioTrack[];
|
||||||
|
changedaudiotrack: AudioTrack | null;
|
||||||
needstrack: boolean;
|
needstrack: boolean;
|
||||||
canairplay: boolean;
|
canairplay: boolean;
|
||||||
playbackrate: number;
|
playbackrate: number;
|
||||||
@ -60,6 +62,7 @@ export interface DisplayInterface extends Listener<DisplayInterfaceEvents> {
|
|||||||
automaticQuality: boolean,
|
automaticQuality: boolean,
|
||||||
preferredQuality: SourceQuality | null,
|
preferredQuality: SourceQuality | null,
|
||||||
): void;
|
): void;
|
||||||
|
changeAudioTrack(audioTrack: AudioTrack): void;
|
||||||
processVideoElement(video: HTMLVideoElement): void;
|
processVideoElement(video: HTMLVideoElement): void;
|
||||||
processContainerElement(container: HTMLElement): void;
|
processContainerElement(container: HTMLElement): void;
|
||||||
toggleFullscreen(): void;
|
toggleFullscreen(): void;
|
||||||
|
@ -75,6 +75,16 @@ export const createDisplaySlice: MakeSlice<DisplaySlice> = (set, get) => ({
|
|||||||
s.currentQuality = quality;
|
s.currentQuality = quality;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
newDisplay.on("audiotracks", (audioTracks) => {
|
||||||
|
set((s) => {
|
||||||
|
s.audioTracks = audioTracks;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
newDisplay.on("changedaudiotrack", (audioTrack) => {
|
||||||
|
set((s) => {
|
||||||
|
s.currentAudioTrack = audioTrack;
|
||||||
|
});
|
||||||
|
});
|
||||||
newDisplay.on("needstrack", (needsTrack) => {
|
newDisplay.on("needstrack", (needsTrack) => {
|
||||||
set((s) => {
|
set((s) => {
|
||||||
s.caption.asTrack = needsTrack;
|
s.caption.asTrack = needsTrack;
|
||||||
|
@ -56,12 +56,20 @@ export interface CaptionListItem {
|
|||||||
hls?: boolean;
|
hls?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AudioTrack {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SourceSlice {
|
export interface SourceSlice {
|
||||||
status: PlayerStatus;
|
status: PlayerStatus;
|
||||||
source: SourceSliceSource | null;
|
source: SourceSliceSource | null;
|
||||||
sourceId: string | null;
|
sourceId: string | null;
|
||||||
qualities: SourceQuality[];
|
qualities: SourceQuality[];
|
||||||
|
audioTracks: AudioTrack[];
|
||||||
currentQuality: SourceQuality | null;
|
currentQuality: SourceQuality | null;
|
||||||
|
currentAudioTrack: AudioTrack | null;
|
||||||
captionList: CaptionListItem[];
|
captionList: CaptionListItem[];
|
||||||
caption: {
|
caption: {
|
||||||
selected: Caption | null;
|
selected: Caption | null;
|
||||||
@ -109,8 +117,10 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
|||||||
source: null,
|
source: null,
|
||||||
sourceId: null,
|
sourceId: null,
|
||||||
qualities: [],
|
qualities: [],
|
||||||
|
audioTracks: [],
|
||||||
captionList: [],
|
captionList: [],
|
||||||
currentQuality: null,
|
currentQuality: null,
|
||||||
|
currentAudioTrack: null,
|
||||||
status: playerStatus.IDLE,
|
status: playerStatus.IDLE,
|
||||||
meta: null,
|
meta: null,
|
||||||
caption: {
|
caption: {
|
||||||
|
Loading…
Reference in New Issue
Block a user