diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index 72007063..7db96bd3 100644 --- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -1599,6 +1599,29 @@ public class ReactExoplayerView extends FrameLayout implements Track audioTrack = exoplayerTrackToGenericTrack(format, groupIndex, selection, group); audioTrack.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate); audioTrack.setSelected(isSelected); + // Encode channel count, bitrate and mimeType into title so JS can read them reliably + // e.g. "English|ch:6|br:640000|mt:audio/ac3" + String existing = audioTrack.getTitle() != null ? audioTrack.getTitle() : ""; + if (format.channelCount != Format.NO_VALUE && format.channelCount > 0) { + existing = existing + "|ch:" + format.channelCount; + } + // Use bitrate, fall back to averageBitrate then peakBitrate + int effectiveBitrate = format.bitrate; + if (effectiveBitrate == Format.NO_VALUE || effectiveBitrate <= 0) { + effectiveBitrate = format.averageBitrate; + } + if (effectiveBitrate == Format.NO_VALUE || effectiveBitrate <= 0) { + effectiveBitrate = format.peakBitrate; + } + if (effectiveBitrate != Format.NO_VALUE && effectiveBitrate > 0) { + existing = existing + "|br:" + effectiveBitrate; + } + if (format.sampleMimeType != null && !format.sampleMimeType.isEmpty()) { + existing = existing + "|mt:" + format.sampleMimeType; + } + if (!existing.isEmpty()) { + audioTrack.setTitle(existing); + } audioTracks.add(audioTrack); } @@ -1794,7 +1817,25 @@ public class ReactExoplayerView extends FrameLayout implements Track track = new Track(); track.setIndex(groupIndex); track.setLanguage(format.language != null ? format.language : "unknown"); - track.setTitle(format.label != null ? format.label : "Track " + (groupIndex + 1)); + String baseTitle = format.label != null ? format.label : ""; + if (format.channelCount != Format.NO_VALUE && format.channelCount > 0) { + baseTitle = baseTitle + "|ch:" + format.channelCount; + } + // Use bitrate, fall back to averageBitrate then peakBitrate + int effectiveBitrate = format.bitrate; + if (effectiveBitrate == Format.NO_VALUE || effectiveBitrate <= 0) { + effectiveBitrate = format.averageBitrate; + } + if (effectiveBitrate == Format.NO_VALUE || effectiveBitrate <= 0) { + effectiveBitrate = format.peakBitrate; + } + if (effectiveBitrate != Format.NO_VALUE && effectiveBitrate > 0) { + baseTitle = baseTitle + "|br:" + effectiveBitrate; + } + if (format.sampleMimeType != null && !format.sampleMimeType.isEmpty()) { + baseTitle = baseTitle + "|mt:" + format.sampleMimeType; + } + track.setTitle(baseTitle); track.setSelected(false); // Don't report selection status - let PlayerView handle it if (format.sampleMimeType != null) track.setMimeType(format.sampleMimeType); diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index d8317ecf..1591f2f4 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -60,6 +60,7 @@ import { storageService } from '../../services/storageService'; import stremioService from '../../services/stremioService'; import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes'; import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils'; +import { buildExoAudioTrackName, buildExoSubtitleTrackName } from './android/components/VideoSurface'; import { useTheme } from '../../contexts/ThemeContext'; import axios from 'axios'; @@ -347,20 +348,22 @@ const AndroidVideoPlayer: React.FC = () => { } if (data.audioTracks) { + console.log('[TrackDebug] raw audioTracks:', JSON.stringify(data.audioTracks)); const formatted = data.audioTracks.map((t: any, i: number) => ({ // react-native-video selectedAudioTrack {type:'index'} uses 0-based list index. id: i, - name: t.title || t.name || `Track ${i + 1}`, + name: buildExoAudioTrackName(t, i), language: t.language })); tracksHook.setRnVideoAudioTracks(formatted); } if (data.textTracks) { + console.log('[TrackDebug] raw textTracks:', JSON.stringify(data.textTracks)); const formatted = data.textTracks.map((t: any, i: number) => ({ // react-native-video selectedTextTrack {type:'index'} uses 0-based list index. // Using `t.index` can be non-unique/misaligned and breaks selection/rendering. id: i, - name: t.title || t.name || `Track ${i + 1}`, + name: buildExoSubtitleTrackName(t, i), language: t.language })); tracksHook.setRnVideoTextTracks(formatted); diff --git a/src/components/player/android/components/VideoSurface.tsx b/src/components/player/android/components/VideoSurface.tsx index 57321d56..ed89ba36 100644 --- a/src/components/player/android/components/VideoSurface.tsx +++ b/src/components/player/android/components/VideoSurface.tsx @@ -78,6 +78,141 @@ const isCodecError = (errorString: string): boolean => { }); }; +const EXOPLAYER_LANG_MAP: Record = { + en: 'English', eng: 'English', + es: 'Spanish', spa: 'Spanish', + fr: 'French', fre: 'French', + de: 'German', ger: 'German', + it: 'Italian', ita: 'Italian', + ja: 'Japanese', jpn: 'Japanese', + ko: 'Korean', kor: 'Korean', + zh: 'Chinese', chi: 'Chinese', + ru: 'Russian', rus: 'Russian', + pt: 'Portuguese', por: 'Portuguese', + hi: 'Hindi', hin: 'Hindi', + ar: 'Arabic', ara: 'Arabic', + nl: 'Dutch', dut: 'Dutch', + pl: 'Polish', pol: 'Polish', + tr: 'Turkish', tur: 'Turkish', +}; + +const exoMimeToCodec = (mimeType?: string): string | null => { + if (!mimeType) return null; + const mime = mimeType.toLowerCase(); + if (mime.includes('eac3') || mime.includes('ec-3')) return 'Dolby Digital Plus'; + if (mime.includes('ac3') || mime.includes('ac-3')) return 'Dolby Digital'; + if (mime.includes('truehd')) return 'TrueHD'; + if (mime.includes('dts.hd') || mime.includes('dts-hd') || mime.includes('dtshd')) return 'DTS-HD'; + if (mime.includes('dts.uhd') || mime.includes('dts:x')) return 'DTS:X'; + if (mime.includes('dts')) return 'DTS'; + if (mime.includes('aac')) return 'AAC'; + if (mime.includes('opus')) return 'Opus'; + if (mime.includes('vorbis')) return 'Vorbis'; + if (mime.includes('mp4a') || mime.includes('mpeg')) return 'MP3'; + if (mime.includes('flac')) return 'FLAC'; + return null; +}; + +export const buildExoAudioTrackName = (t: any, i: number): string => { + const parts: string[] = []; + + // Check both title and name fields for encoded metadata from Java + let rawTitle: string = t.title ?? t.name ?? ''; + let channelCount: number | null = null; + let encodedBitrate: number | null = null; + let encodedMimeType: string | null = null; + + // Extract |ch:N, |br:N, |mt:mime from title + const chMatch = rawTitle.match(/\|ch:(\d+)/); + if (chMatch) channelCount = parseInt(chMatch[1], 10); + + const brMatch = rawTitle.match(/\|br:(\d+)/); + if (brMatch) encodedBitrate = parseInt(brMatch[1], 10); + + const mtMatch = rawTitle.match(/\|mt:([^|]+)/); + if (mtMatch) encodedMimeType = mtMatch[1]; + + // Strip all encoded metadata from the display title + rawTitle = rawTitle.replace(/\|ch:\d+/g, '').replace(/\|br:\d+/g, '').replace(/\|mt:[^|]+/g, '').trim(); + + if (rawTitle) { + if (t.language) { + const lang = EXOPLAYER_LANG_MAP[t.language.toLowerCase()] ?? t.language.toUpperCase(); + if (!rawTitle.toLowerCase().includes(lang.toLowerCase())) { + parts.push(lang); + } + } + parts.push(rawTitle); + } else if (t.language) { + parts.push(EXOPLAYER_LANG_MAP[t.language.toLowerCase()] ?? t.language.toUpperCase()); + } + + // Use mimeType from track object, fall back to encoded mimeType from title + const mimeType = t.mimeType ?? encodedMimeType ?? null; + const codec = exoMimeToCodec(mimeType); + // Only append codec if title doesn't already mention it + const titleLowerForCodec = rawTitle.toLowerCase(); + const codecAlreadyInTitle = titleLowerForCodec.includes('dolby') || + titleLowerForCodec.includes('dts') || + titleLowerForCodec.includes('atmos') || + titleLowerForCodec.includes('aac') || + titleLowerForCodec.includes('truehd') || + titleLowerForCodec.includes('flac'); + if (codec && !codecAlreadyInTitle) parts.push(codec); + + // Use parsed channel count, fall back to bitrate-based guess for AC3/EAC3 + let ch = channelCount ?? t.channelCount ?? null; + if (ch == null) { + const mime = (mimeType ?? '').toLowerCase(); + const br = encodedBitrate ?? t.bitrate ?? 0; + if (mime.includes('ac3') || mime.includes('eac3') || mime.includes('ec-3')) { + if (br >= 500000) ch = 6; + else if (br > 0 && br <= 320000) ch = 2; + } + } + + if (ch != null && ch > 0) { + if (ch === 8) parts.push('7.1'); + else if (ch === 7) parts.push('6.1'); + else if (ch === 6) parts.push('5.1'); + else if (ch === 2) parts.push('2.0'); + else if (ch === 1) parts.push('Mono'); + else parts.push(`${ch}ch`); + } + + const bitrate = encodedBitrate ?? t.bitrate ?? t.bitRate ?? null; + if (bitrate != null && bitrate > 0) { + parts.push(`${Math.round(bitrate / 1000)} kbps`); + } + + return parts.length > 0 ? parts.join(' ') : `Track ${i + 1}`; +}; + + +export const buildExoSubtitleTrackName = (t: any, i: number): string => { + const parts: string[] = []; + const titleLower = (t.title ?? '').toLowerCase(); + if (t.title && t.title.trim()) { + // Prepend language if available and not already in the title + if (t.language) { + const lang = EXOPLAYER_LANG_MAP[t.language.toLowerCase()] ?? t.language.toUpperCase(); + if (!t.title.toLowerCase().includes(lang.toLowerCase())) { + parts.push(lang); + } + } + parts.push(t.title.trim()); + } else if (t.language) { + parts.push(EXOPLAYER_LANG_MAP[t.language.toLowerCase()] ?? t.language.toUpperCase()); + } + if (t.isHearingImpaired || titleLower.includes('sdh') || titleLower.includes('hearing impaired') || titleLower.includes('cc')) { + if (!titleLower.includes('sdh')) parts.push('SDH'); + } + if (t.isForced || titleLower.includes('forced')) { + if (!titleLower.includes('forced')) parts.push('Forced'); + } + return parts.length > 0 ? parts.join(' ') : `Track ${i + 1}`; +}; + export const VideoSurface: React.FC = ({ processedStreamUrl, videoType, @@ -224,13 +359,13 @@ export const VideoSurface: React.FC = ({ const handleExoLoad = (data: any) => { const audioTracks = data.audioTracks?.map((t: any, i: number) => ({ id: i, - name: t.title || t.language || `Track ${i + 1}`, + name: buildExoAudioTrackName(t, i), language: t.language, })) ?? []; const subtitleTracks = data.textTracks?.map((t: any, i: number) => ({ id: i, - name: t.title || t.language || `Track ${i + 1}`, + name: buildExoSubtitleTrackName(t, i), language: t.language, })) ?? []; diff --git a/src/components/player/utils/playerUtils.ts b/src/components/player/utils/playerUtils.ts index 8514eac5..e2124358 100644 --- a/src/components/player/utils/playerUtils.ts +++ b/src/components/player/utils/playerUtils.ts @@ -102,18 +102,20 @@ export const getTrackDisplayName = (track: { name?: string, id: number, language return track.name; } - // If the track name contains detailed information (like codec, bitrate, etc.), use it as-is + // If the track name contains detailed information, use it as-is if (track.name && (track.name.includes('DDP') || track.name.includes('DTS') || track.name.includes('AAC') || - track.name.includes('Kbps') || track.name.includes('Atmos') || track.name.includes('~'))) { + track.name.includes('EAC3') || track.name.includes('AC3') || track.name.includes('TrueHD') || + track.name.includes('Dolby') || track.name.includes('FLAC') || track.name.includes('Opus') || + track.name.includes('Kbps') || track.name.includes('kbps') || track.name.includes('Atmos') || + track.name.includes('5.1') || track.name.includes('7.1') || track.name.includes('6.1') || track.name.includes('2.0') || + track.name.includes('SDH') || track.name.includes('Forced') || track.name.includes('~'))) { return track.name; } - // If we have a language field, use that for better display (only for simple track names) - if (track.language && track.language !== 'Unknown') { - const formattedLanguage = formatLanguage(track.language); - if (formattedLanguage !== 'Unknown' && !formattedLanguage.includes('Unknown')) { - return formattedLanguage; - } + // If name is a rich multi-word label (more than one word and not a generic track name), use it as-is + const genericTrackMatch = track.name.match(/^(Audio|Track|Subtitle)\s+(\d+)$/i); + if (!genericTrackMatch && track.name.trim().includes(' ')) { + return track.name; } // Try to extract language from name like "Some Info - [English]" @@ -122,10 +124,12 @@ export const getTrackDisplayName = (track: { name?: string, id: number, language return languageMatch[1]; } - // Handle generic VLC track names like "Audio 1", "Track 1" - const genericTrackMatch = track.name.match(/^(Audio|Track)\s+(\d+)$/i); + // Handle generic VLC track names like "Audio 1", "Track 1" — use language if available if (genericTrackMatch) { - return `Audio ${genericTrackMatch[2]}`; + if (track.language && track.language !== 'Unknown') { + return formatLanguage(track.language); + } + return track.name; } // Check for common language patterns in the name @@ -231,4 +235,4 @@ export const getHlsHeaders = () => { ...defaultAndroidHeaders, 'Accept': 'application/x-mpegURL, application/vnd.apple.mpegurl, application/json, text/plain', }; -}; \ No newline at end of file +};