diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json
index dc4d6ed1..60c08bfe 100644
--- a/src/assets/locales/en.json
+++ b/src/assets/locales/en.json
@@ -294,6 +294,7 @@
"enableSubtitles": "Enable Subtitles",
"experienceSection": "Viewing experience",
"playbackItem": "Playback settings",
+ "audioItem": "Audio",
"qualityItem": "Quality",
"sourceItem": "Video sources",
"subtitleItem": "Subtitle settings",
diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx
index 68e36b83..5900df44 100644
--- a/src/components/player/atoms/Settings.tsx
+++ b/src/components/player/atoms/Settings.tsx
@@ -14,6 +14,7 @@ import { Menu } from "@/components/player/internals/ContextMenu";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { usePlayerStore } from "@/stores/player/store";
+import { AudioView } from "./settings/AudioView";
import { CaptionSettingsView } from "./settings/CaptionSettingsView";
import { CaptionsView } from "./settings/CaptionsView";
import { DownloadRoutes } from "./settings/Downloads";
@@ -46,6 +47,11 @@ function SettingsOverlay({ id }: { id: string }) {
+
+
+
+
+
diff --git a/src/components/player/atoms/settings/AudioView.tsx b/src/components/player/atoms/settings/AudioView.tsx
new file mode 100644
index 00000000..a2c9c7f6
--- /dev/null
+++ b/src/components/player/atoms/settings/AudioView.tsx
@@ -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 (
+
+
+
+
+
+ {props.children}
+
+
+ );
+}
+
+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 (
+ <>
+ router.navigate("/")}>Audio
+
+ {audioTracks.map((v) => (
+ change(v) : undefined}
+ >
+ {getPrettyLanguageNameFromLocale(v.language) ?? unknownChoice}
+
+ ))}
+
+ >
+ );
+}
diff --git a/src/components/player/atoms/settings/SettingsMenu.tsx b/src/components/player/atoms/settings/SettingsMenu.tsx
index 8321c562..8198d87b 100644
--- a/src/components/player/atoms/settings/SettingsMenu.tsx
+++ b/src/components/player/atoms/settings/SettingsMenu.tsx
@@ -16,6 +16,7 @@ export function SettingsMenu({ id }: { id: string }) {
const { t } = useTranslation();
const router = useOverlayRouter(id);
const currentQuality = usePlayerStore((s) => s.currentQuality);
+ const currentAudioTrack = usePlayerStore((s) => s.currentAudioTrack);
const selectedCaptionLanguage = usePlayerStore(
(s) => s.caption.selected?.language,
);
@@ -35,6 +36,11 @@ export function SettingsMenu({ id }: { id: string }) {
t("player.menus.subtitles.unknownLanguage")
: undefined;
+ const selectedAudioLanguagePretty = currentAudioTrack
+ ? getPrettyLanguageNameFromLocale(currentAudioTrack.language) ??
+ t("player.menus.subtitles.unknownLanguage")
+ : undefined;
+
const source = usePlayerStore((s) => s.source);
const downloadable = source?.type === "file" || source?.type === "hls";
@@ -51,6 +57,15 @@ export function SettingsMenu({ id }: { id: string }) {
>
{t("player.menus.settings.qualityItem")}
+ {currentAudioTrack && (
+ router.navigate("/audio")}
+ rightText={selectedAudioLanguagePretty ?? undefined}
+ >
+ {t("player.menus.settings.audioItem")}
+
+ )}
+
router.navigate("/source")}
rightText={sourceName}
diff --git a/src/components/player/display/base.ts b/src/components/player/display/base.ts
index 51e6d7bb..8e155f69 100644
--- a/src/components/player/display/base.ts
+++ b/src/components/player/display/base.ts
@@ -81,6 +81,24 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
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() {
if (videoElement && canPlayHlsNatively(videoElement)) {
return; // nothing to change
@@ -155,6 +173,7 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
if (!hls) return;
reportLevels();
setupQualityForHls();
+ reportAudioTracks();
if (isExtensionActiveCached()) {
hls.on(Hls.Events.LEVEL_LOADED, async (_, data) => {
@@ -464,5 +483,18 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
hls?.setSubtitleOption({ lang });
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",
+ });
+ },
};
}
diff --git a/src/components/player/display/chromecast.ts b/src/components/player/display/chromecast.ts
index 1a318f16..48f8b2ab 100644
--- a/src/components/player/display/chromecast.ts
+++ b/src/components/player/display/chromecast.ts
@@ -283,5 +283,8 @@ export function makeChromecastDisplayInterface(
async setSubtitlePreference() {
return Promise.resolve();
},
+ changeAudioTrack() {
+ // cant change audio tracks
+ },
};
}
diff --git a/src/components/player/display/displayInterface.ts b/src/components/player/display/displayInterface.ts
index 134bef44..2f17aaed 100644
--- a/src/components/player/display/displayInterface.ts
+++ b/src/components/player/display/displayInterface.ts
@@ -1,7 +1,7 @@
import { MediaPlaylist } from "hls.js";
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 { Listener } from "@/utils/events";
@@ -25,6 +25,8 @@ export type DisplayInterfaceEvents = {
loading: boolean;
qualities: SourceQuality[];
changedquality: SourceQuality | null;
+ audiotracks: AudioTrack[];
+ changedaudiotrack: AudioTrack | null;
needstrack: boolean;
canairplay: boolean;
playbackrate: number;
@@ -60,6 +62,7 @@ export interface DisplayInterface extends Listener {
automaticQuality: boolean,
preferredQuality: SourceQuality | null,
): void;
+ changeAudioTrack(audioTrack: AudioTrack): void;
processVideoElement(video: HTMLVideoElement): void;
processContainerElement(container: HTMLElement): void;
toggleFullscreen(): void;
diff --git a/src/stores/player/slices/display.ts b/src/stores/player/slices/display.ts
index 86743ccd..63403376 100644
--- a/src/stores/player/slices/display.ts
+++ b/src/stores/player/slices/display.ts
@@ -75,6 +75,16 @@ export const createDisplaySlice: MakeSlice = (set, get) => ({
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) => {
set((s) => {
s.caption.asTrack = needsTrack;
diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts
index 5d04ef49..eb2ce9e1 100644
--- a/src/stores/player/slices/source.ts
+++ b/src/stores/player/slices/source.ts
@@ -56,12 +56,20 @@ export interface CaptionListItem {
hls?: boolean;
}
+export interface AudioTrack {
+ id: string;
+ label: string;
+ language: string;
+}
+
export interface SourceSlice {
status: PlayerStatus;
source: SourceSliceSource | null;
sourceId: string | null;
qualities: SourceQuality[];
+ audioTracks: AudioTrack[];
currentQuality: SourceQuality | null;
+ currentAudioTrack: AudioTrack | null;
captionList: CaptionListItem[];
caption: {
selected: Caption | null;
@@ -109,8 +117,10 @@ export const createSourceSlice: MakeSlice = (set, get) => ({
source: null,
sourceId: null,
qualities: [],
+ audioTracks: [],
captionList: [],
currentQuality: null,
+ currentAudioTrack: null,
status: playerStatus.IDLE,
meta: null,
caption: {