updated exoplayer sub behaviour

This commit is contained in:
tapframe 2026-01-11 22:46:54 +05:30
parent c20c2713d0
commit c728f4ea8d
5 changed files with 532 additions and 96298 deletions

View file

@ -10,12 +10,14 @@ import android.widget.FrameLayout
import android.widget.TextView
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import com.brentvatne.common.api.ResizeMode
import com.brentvatne.common.api.SubtitleStyle
@ -53,15 +55,58 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
}
/**
* Subtitles rendered in a full-size overlay (NOT inside PlayerView's content frame).
* This keeps subtitles anchored in-place even when the video surface/content frame moves
* due to aspect ratio / resizeMode changes.
*
* Controlled by SubtitleStyle.subtitlesFollowVideo.
*/
private val overlaySubtitleView = SubtitleView(context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
visibility = View.GONE
// We control styling via SubtitleStyle; don't pull Android system caption defaults.
setApplyEmbeddedStyles(true)
setApplyEmbeddedFontSizes(true)
}
private fun updateSubtitleRenderingMode() {
val internalSubtitleView = playerView.subtitleView
val followVideo = localStyle.subtitlesFollowVideo
val shouldShow = localStyle.opacity != 0.0f
if (followVideo) {
internalSubtitleView?.visibility = if (shouldShow) View.VISIBLE else View.GONE
overlaySubtitleView.visibility = View.GONE
} else {
// Hard-disable PlayerView's internal subtitle view. PlayerView can recreate/toggle this view
// during resize/layout, so we re-assert this in multiple lifecycle points.
internalSubtitleView?.visibility = View.GONE
internalSubtitleView?.alpha = 0f
overlaySubtitleView.visibility = if (shouldShow) View.VISIBLE else View.GONE
overlaySubtitleView.alpha = 1f
}
}
init {
// Add PlayerView with explicit layout parameters
val playerViewLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
addView(playerView, playerViewLayoutParams)
// Add overlay subtitles above PlayerView (so it doesn't move with video content frame)
val subtitleOverlayLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
addView(overlaySubtitleView, subtitleOverlayLayoutParams)
// Add live badge with its own layout parameters
val liveBadgeLayoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
liveBadgeLayoutParams.setMargins(16, 16, 16, 16)
addView(liveBadge, liveBadgeLayoutParams)
// PlayerView may internally recreate its subtitle view during relayouts (e.g. resizeMode changes).
// Ensure our rendering mode is re-applied whenever PlayerView lays out.
playerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
updateSubtitleRenderingMode()
}
}
fun setPlayer(player: ExoPlayer?) {
@ -81,6 +126,10 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
playerView.resizeMode = resizeMode
}
}
// Re-assert subtitle rendering mode for the current style.
updateSubtitleRenderingMode()
applySubtitleStyle(localStyle)
}
fun getPlayerView(): PlayerView = playerView
@ -114,6 +163,8 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
}
private fun applySubtitleStyle(style: SubtitleStyle) {
updateSubtitleRenderingMode()
playerView.subtitleView?.let { subtitleView ->
// Important:
// Avoid inheriting Android system caption settings via setUserDefaultStyle(),
@ -141,7 +192,9 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
// Text size: if not provided, fall back to user default size.
if (style.fontSize > 0) {
subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, style.fontSize.toFloat())
// Use DIP so the value matches React Native's dp-based fontSize more closely.
// SP would multiply by system fontScale and makes "30" look larger than RN "30".
subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_DIP, style.fontSize.toFloat())
} else {
subtitleView.setUserDefaultTextSize()
}
@ -158,7 +211,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
// Use Media3 SubtitleView's bottomPaddingFraction (moves cues up) rather than raw view padding.
if (style.paddingBottom > 0 && playerView.height > 0) {
val fraction = (style.paddingBottom.toFloat() / playerView.height.toFloat())
.coerceIn(0f, 0.5f)
.coerceIn(0f, 0.9f)
subtitleView.setBottomPaddingFraction(fraction)
}
@ -169,6 +222,59 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
subtitleView.visibility = android.view.View.GONE
}
}
// Apply the same styling to the overlay subtitle view.
run {
val subtitleView = overlaySubtitleView
val resolvedTextColor = style.textColor ?: CaptionStyleCompat.DEFAULT.foregroundColor
val resolvedBackgroundColor = style.backgroundColor ?: Color.TRANSPARENT
val resolvedEdgeColor = style.edgeColor ?: Color.BLACK
val resolvedEdgeType = when (style.edgeType?.lowercase()) {
"outline" -> CaptionStyleCompat.EDGE_TYPE_OUTLINE
"shadow" -> CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
else -> CaptionStyleCompat.EDGE_TYPE_NONE
}
val captionStyle = CaptionStyleCompat(
resolvedTextColor,
resolvedBackgroundColor,
Color.TRANSPARENT,
resolvedEdgeType,
resolvedEdgeColor,
null
)
subtitleView.setStyle(captionStyle)
if (style.fontSize > 0) {
// Use DIP so the value matches React Native's dp-based fontSize more closely.
subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_DIP, style.fontSize.toFloat())
} else {
subtitleView.setUserDefaultTextSize()
}
subtitleView.setPadding(
style.paddingLeft,
style.paddingTop,
style.paddingRight,
0
)
// Bottom offset relative to the full view height (stable even when video content frame moves).
val h = height.takeIf { it > 0 } ?: subtitleView.height
if (style.paddingBottom > 0 && h > 0) {
val fraction = (style.paddingBottom.toFloat() / h.toFloat())
.coerceIn(0f, 0.9f)
subtitleView.setBottomPaddingFraction(fraction)
} else {
subtitleView.setBottomPaddingFraction(0f)
}
if (style.opacity != 0.0f) {
subtitleView.alpha = style.opacity
}
}
}
fun setShutterColor(color: Int) {
@ -259,6 +365,13 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
}
private val playerListener = object : Player.Listener {
override fun onCues(cueGroup: CueGroup) {
// Keep overlay subtitles in sync. This does NOT interfere with PlayerView's own subtitle rendering.
// When subtitlesFollowVideo=false, overlaySubtitleView is the visible one.
updateSubtitleRenderingMode()
overlaySubtitleView.setCues(cueGroup.cues)
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
playerView.post {
playerView.requestLayout()
@ -321,6 +434,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
playerView.resizeMode = resizeMode
}
// Re-apply bottomPaddingFraction once we have a concrete height.
updateSubtitleRenderingMode()
applySubtitleStyle(localStyle)
}
}

File diff suppressed because one or more lines are too long

View file

@ -820,6 +820,7 @@ const AndroidVideoPlayer: React.FC = () => {
subtitleBorderColor={subtitleOutlineColor}
subtitleShadowEnabled={subtitleTextShadow}
subtitlePosition={Math.max(50, 100 - Math.floor(subtitleBottomOffset * 0.3))} // Scale offset to MPV range
subtitleBottomOffset={subtitleBottomOffset}
subtitleDelay={subtitleOffsetSec}
subtitleAlignment={subtitleAlign}
/>

View file

@ -77,6 +77,7 @@ interface VideoSurfaceProps {
subtitleBorderColor?: string;
subtitleShadowEnabled?: boolean;
subtitlePosition?: number;
subtitleBottomOffset?: number;
subtitleDelay?: number;
subtitleAlignment?: 'left' | 'center' | 'right';
}
@ -128,6 +129,7 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
subtitleBorderColor,
subtitleShadowEnabled,
subtitlePosition,
subtitleBottomOffset,
subtitleDelay,
subtitleAlignment,
}) => {
@ -327,12 +329,13 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
// - fontSize, paddingTop/Bottom/Left/Right, opacity, subtitlesFollowVideo
// - PLUS: textColor, backgroundColor, edgeType, edgeColor (outline/shadow)
subtitleStyle={{
// Convert MPV-scaled size back to ExoPlayer scale (~1.5x conversion was applied)
fontSize: subtitleSize ? Math.round(subtitleSize / 1.5) : 18,
// Convert MPV-scaled size back to UI size (AndroidVideoPlayer passes MPV-scaled values here)
fontSize: subtitleSize ? Math.round(subtitleSize / 1.5) : 28,
paddingTop: 0,
// Convert MPV position (0=top, 100=bottom) to paddingBottom
// Higher MPV position = less padding from bottom
paddingBottom: subtitlePosition ? Math.max(20, Math.round((100 - subtitlePosition) * 2)) : 60,
// IMPORTANT:
// Use the same unit as external subtitles (RN CustomSubtitles uses dp bottomOffset directly).
// Using MPV's subtitlePosition mapping makes internal/external offsets feel inconsistent.
paddingBottom: Math.max(0, Math.round(subtitleBottomOffset ?? 0)),
paddingLeft: 16,
paddingRight: 16,
// Opacity controls entire subtitle view visibility

View file

@ -1311,6 +1311,10 @@ class CatalogService {
const addons = await this.getAllAddons();
const byAddon: AddonSearchResults[] = [];
// Get manifests separately to ensure we have correct URLs
const manifests = await stremioService.getInstalledAddonsAsync();
const manifestMap = new Map(manifests.map(m => [m.id, m]));
// Find all addons that support search
const searchableAddons = addons.filter(addon => {
if (!addon.catalogs) return false;
@ -1330,6 +1334,13 @@ class CatalogService {
// Search each addon and keep results grouped
for (const addon of searchableAddons) {
// Get the manifest to ensure we have the correct URL
const manifest = manifestMap.get(addon.id);
if (!manifest) {
logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`);
continue;
}
const searchableCatalogs = (addon.catalogs || []).filter(catalog => {
const extraSupported = catalog.extraSupported || [];
const extra = catalog.extra || [];
@ -1339,7 +1350,7 @@ class CatalogService {
// Search all catalogs for this addon in parallel
const catalogPromises = searchableCatalogs.map(catalog =>
this.searchAddonCatalog(addon, catalog.type, catalog.id, trimmedQuery)
this.searchAddonCatalog(manifest, catalog.type, catalog.id, trimmedQuery)
);
const catalogResults = await Promise.allSettled(catalogPromises);
@ -1409,6 +1420,11 @@ class CatalogService {
logger.log('Live search across addons for:', trimmedQuery);
const addons = await this.getAllAddons();
logger.log(`Total addons available: ${addons.length}`);
// Get manifests separately to ensure we have correct URLs
const manifests = await stremioService.getInstalledAddonsAsync();
const manifestMap = new Map(manifests.map(m => [m.id, m]));
// Determine searchable addons
const searchableAddons = addons.filter(addon =>
@ -1418,6 +1434,13 @@ class CatalogService {
)
);
logger.log(`Found ${searchableAddons.length} searchable addons:`, searchableAddons.map(a => `${a.name} (${a.id})`).join(', '));
if (searchableAddons.length === 0) {
logger.warn('No searchable addons found. Make sure you have addons installed that support search functionality.');
return;
}
// Global dedupe across emitted results
const globalSeen = new Set<string>();
@ -1425,14 +1448,23 @@ class CatalogService {
searchableAddons.map(async (addon) => {
if (controller.cancelled) return;
try {
// Get the manifest to ensure we have the correct URL
const manifest = manifestMap.get(addon.id);
if (!manifest) {
logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`);
return;
}
const searchableCatalogs = (addon.catalogs || []).filter(c =>
(c.extraSupported && c.extraSupported.includes('search')) ||
(c.extra && c.extra.some(e => e.name === 'search'))
);
logger.log(`Searching ${addon.name} (${addon.id}) with ${searchableCatalogs.length} searchable catalogs`);
// Fetch all catalogs for this addon in parallel
const settled = await Promise.allSettled(
searchableCatalogs.map(c => this.searchAddonCatalog(addon, c.type, c.id, trimmedQuery))
searchableCatalogs.map(c => this.searchAddonCatalog(manifest, c.type, c.id, trimmedQuery))
);
if (controller.cancelled) return;
@ -1440,9 +1472,15 @@ class CatalogService {
for (const s of settled) {
if (s.status === 'fulfilled' && Array.isArray(s.value)) {
addonResults.push(...s.value);
} else if (s.status === 'rejected') {
logger.warn(`Search failed for catalog in ${addon.name}:`, s.reason);
}
}
if (addonResults.length === 0) return;
if (addonResults.length === 0) {
logger.log(`No results from ${addon.name}`);
return;
}
// Dedupe within addon and against global
const localSeen = new Set<string>();
@ -1455,10 +1493,11 @@ class CatalogService {
});
if (unique.length > 0 && !controller.cancelled) {
logger.log(`Emitting ${unique.length} results from ${addon.name}`);
onAddonResults({ addonId: addon.id, addonName: addon.name, results: unique });
}
} catch (e) {
// ignore individual addon errors
logger.error(`Error searching addon ${addon.name} (${addon.id}):`, e);
}
})
);
@ -1474,14 +1513,14 @@ class CatalogService {
* Search a specific catalog from a specific addon.
* Handles URL construction for both Cinemeta (hardcoded) and other addons (dynamic).
*
* @param addon - The addon manifest containing id, name, and url
* @param manifest - The addon manifest containing id, name, and url
* @param type - Content type (movie, series, anime, etc.)
* @param catalogId - The catalog ID to search within
* @param query - The search query string
* @returns Promise<StreamingContent[]> - Search results from this specific addon catalog
*/
private async searchAddonCatalog(
addon: any,
manifest: Manifest,
type: string,
catalogId: string,
query: string
@ -1490,7 +1529,7 @@ class CatalogService {
let url: string;
// Special handling for Cinemeta (hardcoded URL)
if (addon.id === 'com.linvo.cinemeta') {
if (manifest.id === 'com.linvo.cinemeta') {
const encodedCatalogId = encodeURIComponent(catalogId);
const encodedQuery = encodeURIComponent(query);
url = `https://v3-cinemeta.strem.io/catalog/${type}/${encodedCatalogId}/search=${encodedQuery}.json`;
@ -1498,12 +1537,13 @@ class CatalogService {
// Handle other addons
else {
// Choose best available URL
const chosenUrl: string | undefined = addon.url || addon.originalUrl || addon.transportUrl;
const chosenUrl: string | undefined = manifest.url || manifest.originalUrl;
if (!chosenUrl) {
logger.warn(`Addon ${addon.name} has no URL, skipping search`);
logger.warn(`Addon ${manifest.name} (${manifest.id}) has no URL, skipping search`);
return [];
}
// Extract base URL and preserve query params
// Extract base URL and preserve query params (same logic as stremioService.getAddonBaseURL)
const [baseUrlPart, queryParams] = chosenUrl.split('?');
let cleanBaseUrl = baseUrlPart.replace(/manifest\.json$/, '').replace(/\/$/, '');
@ -1514,6 +1554,8 @@ class CatalogService {
const encodedCatalogId = encodeURIComponent(catalogId);
const encodedQuery = encodeURIComponent(query);
// Try path-style URL first (per Stremio protocol)
url = `${cleanBaseUrl}/catalog/${type}/${encodedCatalogId}/search=${encodedQuery}.json`;
// Append original query params if they existed
@ -1522,7 +1564,7 @@ class CatalogService {
}
}
logger.log(`Searching ${addon.name} (${type}/${catalogId}):`, url);
logger.log(`Searching ${manifest.name} (${type}/${catalogId}):`, url);
const response = await axios.get<{ metas: any[] }>(url, {
timeout: 10000, // 10 second timeout per addon
@ -1533,10 +1575,10 @@ class CatalogService {
if (metas.length > 0) {
const items = metas.map(meta => {
const content = this.convertMetaToStreamingContent(meta);
content.addonId = addon.id;
content.addonId = manifest.id;
return content;
});
logger.log(`Found ${items.length} results from ${addon.name}`);
logger.log(`Found ${items.length} results from ${manifest.name}`);
return items;
}
@ -1546,7 +1588,11 @@ class CatalogService {
const errorMsg = error?.response?.status
? `HTTP ${error.response.status}`
: error?.message || 'Unknown error';
logger.error(`Search failed for ${addon.name} (${type}/${catalogId}): ${errorMsg}`);
const errorUrl = error?.config?.url || 'unknown URL';
logger.error(`Search failed for ${manifest.name} (${type}/${catalogId}) at ${errorUrl}: ${errorMsg}`);
if (error?.response?.data) {
logger.error(`Response data:`, error.response.data);
}
return [];
}
}