fix(mal): production-ready improvements for MAL sync and mapping logic

This commit is contained in:
paregi12 2026-03-12 18:03:54 +05:30
parent dad5beee67
commit 433d3a21b9
5 changed files with 270 additions and 503 deletions

View file

@ -110,21 +110,23 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
}
fun setPlayer(player: ExoPlayer?) {
val currentPlayer = playerView.player
if (currentPlayer != null) {
currentPlayer.removeListener(playerListener)
}
playerView.player = player
player?.addListener(playerListener)
}
if (player != null) {
player.addListener(playerListener)
// Apply pending resize mode if we have one
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}
fun setResizeMode(@ResizeMode.Mode mode: Int) {
val resizeMode = when (mode) {
ResizeMode.RESIZE_MODE_FIT -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
ResizeMode.RESIZE_MODE_FIXED_WIDTH -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
ResizeMode.RESIZE_MODE_FIXED_HEIGHT -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
ResizeMode.RESIZE_MODE_FILL -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FILL
ResizeMode.RESIZE_MODE_ZOOM -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
else -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
}
if (playerView.width > 0 && playerView.height > 0) {
playerView.resizeMode = resizeMode
} else {
pendingResizeMode = resizeMode
}
// Re-assert subtitle rendering mode for the current style.
@ -134,27 +136,34 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
fun getPlayerView(): PlayerView = playerView
fun setResizeMode(@ResizeMode.Mode resizeMode: Int) {
val targetResizeMode = when (resizeMode) {
ResizeMode.RESIZE_MODE_FILL -> AspectRatioFrameLayout.RESIZE_MODE_FILL
ResizeMode.RESIZE_MODE_CENTER_CROP -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
ResizeMode.RESIZE_MODE_FIT -> AspectRatioFrameLayout.RESIZE_MODE_FIT
ResizeMode.RESIZE_MODE_FIXED_WIDTH -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
ResizeMode.RESIZE_MODE_FIXED_HEIGHT -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
else -> AspectRatioFrameLayout.RESIZE_MODE_FIT
}
fun setShowSubtitleButton(show: Boolean) {
playerView.setShowSubtitleButton(show)
}
// Apply the resize mode to PlayerView immediately
playerView.resizeMode = targetResizeMode
fun setUseController(useController: Boolean) {
playerView.useController = useController
}
// Store it for reapplication if needed
pendingResizeMode = targetResizeMode
fun setControllerHideOnTouch(hideOnTouch: Boolean) {
playerView.setControllerHideOnTouch(hideOnTouch)
}
// Force PlayerView to recalculate its layout
playerView.requestLayout()
fun setControllerAutoShow(autoShow: Boolean) {
playerView.setControllerAutoShow(autoShow)
}
// Also request layout on the parent to ensure proper sizing
requestLayout()
fun setControllerShowTimeoutMs(timeoutMs: Int) {
playerView.controllerShowTimeoutMs = timeoutMs
}
fun isControllerVisible(): Boolean = playerView.isControllerFullyVisible
fun hideController() {
playerView.hideController()
}
fun showController() {
playerView.showController()
}
fun setSubtitleStyle(style: SubtitleStyle) {
@ -281,89 +290,16 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
playerView.setShutterBackgroundColor(color)
}
fun updateSurfaceView(viewType: Int) {
// TODO: Implement proper surface type switching if needed
fun setShowLiveBadge(show: Boolean) {
liveBadge.visibility = if (show) View.VISIBLE else View.GONE
}
val isPlaying: Boolean
get() = playerView.player?.isPlaying ?: false
fun invalidateAspectRatio() {
// PlayerView handles aspect ratio automatically through its internal AspectRatioFrameLayout
playerView.requestLayout()
// Reapply the current resize mode to ensure it's properly set
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
playerView.post {
playerView.requestLayout()
}
}
fun setUseController(useController: Boolean) {
playerView.useController = useController
if (useController) {
// Ensure proper touch handling when controls are enabled
playerView.controllerAutoShow = true
playerView.controllerHideOnTouch = true
// Show controls immediately when enabled
playerView.showController()
}
}
fun showController() {
playerView.showController()
}
fun hideController() {
playerView.hideController()
}
fun setControllerShowTimeoutMs(showTimeoutMs: Int) {
playerView.controllerShowTimeoutMs = showTimeoutMs
}
fun setControllerAutoShow(autoShow: Boolean) {
playerView.controllerAutoShow = autoShow
}
fun setControllerHideOnTouch(hideOnTouch: Boolean) {
playerView.controllerHideOnTouch = hideOnTouch
}
fun setFullscreenButtonClickListener(listener: PlayerView.FullscreenButtonClickListener?) {
playerView.setFullscreenButtonClickListener(listener)
}
fun setShowSubtitleButton(show: Boolean) {
playerView.setShowSubtitleButton(show)
}
fun isControllerVisible(): Boolean = playerView.isControllerFullyVisible
fun setControllerVisibilityListener(listener: PlayerView.ControllerVisibilityListener?) {
playerView.setControllerVisibilityListener(listener)
}
override fun addOnLayoutChangeListener(listener: View.OnLayoutChangeListener) {
playerView.addOnLayoutChangeListener(listener)
}
override fun setFocusable(focusable: Boolean) {
playerView.isFocusable = focusable
}
private fun updateLiveUi() {
val player = playerView.player ?: return
val isLive = player.isCurrentMediaItemLive
val seekable = player.isCurrentMediaItemSeekable
// Show/hide badge
liveBadge.visibility = if (isLive) View.VISIBLE else View.GONE
// Disable/enable scrubbing based on seekable
val timeBar = playerView.findViewById<DefaultTimeBar?>(androidx.media3.ui.R.id.exo_progress)
timeBar?.isEnabled = !isLive || seekable
}
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.
@ -375,61 +311,15 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
playerView.post {
playerView.requestLayout()
// Reapply resize mode to ensure it's properly set after timeline changes
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}
}
updateLiveUi()
}
override fun onEvents(player: Player, events: Player.Events) {
if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION) ||
events.contains(Player.EVENT_IS_PLAYING_CHANGED)
) {
updateLiveUi()
}
// Handle video size changes which affect aspect ratio
if (events.contains(Player.EVENT_VIDEO_SIZE_CHANGED)) {
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}
playerView.requestLayout()
requestLayout()
}
}
}
companion object {
private const val TAG = "ExoPlayerView"
}
/**
* React Native (Yoga) can sometimes defer layout passes that are required by
* PlayerView for its child views (controller overlay, surface view, subtitle view, ).
* This helper forces a second measure / layout after RN finishes, ensuring the
* internal views receive the final size. The same approach is used in the v7
* implementation (see VideoView.kt) and in React Native core (Toolbar example [link]).
*/
private val layoutRunnable = Runnable {
measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
)
layout(left, top, right, bottom)
}
override fun requestLayout() {
super.requestLayout()
// Post a second layout pass so the ExoPlayer internal views get correct bounds.
post(layoutRunnable)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
if (changed) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
if (width > 0 && height > 0) {
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}

View file

@ -189,7 +189,7 @@ export const useWatchProgress = (
currentTmdbId
);
} else if (type === 'movie' && currentImdbId) {
watchedService.markMovieAsWatched(currentImdbId, new Date(), currentMalId, currentTmdbId);
watchedService.markMovieAsWatched(currentImdbId, new Date(), currentMalId, currentTmdbId, title);
}
}
} catch (error) {

View file

@ -375,28 +375,31 @@ export const MalSync = {
const currentLibrary = await catalogService.getLibraryItems();
const libraryIds = new Set(currentLibrary.map(l => l.id));
// Process items in parallel
await Promise.all(response.data.map(async (item) => {
const malId = item.node.id;
const { imdbId } = await MalSync.getIdsFromMalId(malId);
if (imdbId && !libraryIds.has(imdbId)) {
const type = item.node.media_type === 'movie' ? 'movie' : 'series';
logger.log(`[MalSync] Auto-adding to library: ${item.node.title} (${imdbId})`);
// Process items in small batches to avoid rate limiting
for (let i = 0; i < response.data.length; i += 5) {
const batch = response.data.slice(i, i + 5);
await Promise.all(batch.map(async (item) => {
const malId = item.node.id;
const { imdbId } = await MalSync.getIdsFromMalId(malId);
await catalogService.addToLibrary({
id: imdbId,
type: type,
name: item.node.title,
poster: item.node.main_picture?.large || item.node.main_picture?.medium || '',
posterShape: 'poster',
year: item.node.start_season?.year,
description: '',
genres: [],
inLibrary: true,
});
}
}));
if (imdbId && !libraryIds.has(imdbId)) {
const type = item.node.media_type === 'movie' ? 'movie' : 'series';
logger.log(`[MalSync] Auto-adding to library: ${item.node.title} (${imdbId})`);
await catalogService.addToLibrary({
id: imdbId,
type: type,
name: item.node.title,
poster: item.node.main_picture?.large || item.node.main_picture?.medium || '',
posterShape: 'poster',
year: item.node.start_season?.year,
description: '',
genres: [],
inLibrary: true,
});
}
}));
}
} catch (e) {
logger.error('[MalSync] syncMalWatchingToLibrary failed:', e);
}

View file

@ -203,7 +203,8 @@ class WatchedService {
imdbId: string,
watchedAt: Date = new Date(),
malId?: number,
tmdbId?: number
tmdbId?: number,
title?: string
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
try {
logger.log(`[WatchedService] Marking movie as watched: ${imdbId}`);
@ -221,7 +222,7 @@ class WatchedService {
const malToken = MalAuth.getToken();
if (malToken) {
MalSync.scrobbleEpisode(
'', // Don't use IMDb ID as title fallback (unlikely to match)
title || '', // Use real title if provided for search fallback
1,
1,
'movie',