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