mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
updated exoplayer sub behaviour
This commit is contained in:
parent
c20c2713d0
commit
c728f4ea8d
5 changed files with 532 additions and 96298 deletions
|
|
@ -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
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue