Merge pull request #540 from chrisk325/subtitle-info

Feat: add audio track info and subtitle track info to exoplayer
This commit is contained in:
Nayif 2026-02-27 22:10:01 +05:30 committed by GitHub
commit 18f49f0853
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 200 additions and 17 deletions

View file

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

View file

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

View file

@ -78,6 +78,141 @@ const isCodecError = (errorString: string): boolean => {
});
};
const EXOPLAYER_LANG_MAP: Record<string, string> = {
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<VideoSurfaceProps> = ({
processedStreamUrl,
videoType,
@ -224,13 +359,13 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
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,
})) ?? [];

View file

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