Merge pull request #1038 from movie-web/feature/audiotracks

Support for HLS audio tracks
This commit is contained in:
William Oldham 2024-03-25 23:10:37 +00:00 committed by GitHub
commit 94c4e71756
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 146 additions and 1 deletions

View File

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

View File

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

View 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>
</>
);
}

View File

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

View File

@ -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",
});
},
}; };
} }

View File

@ -283,5 +283,8 @@ export function makeChromecastDisplayInterface(
async setSubtitlePreference() { async setSubtitlePreference() {
return Promise.resolve(); return Promise.resolve();
}, },
changeAudioTrack() {
// cant change audio tracks
},
}; };
} }

View File

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

View File

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

View File

@ -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: {