diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt index f2f9cd4f..31f4bd00 100644 --- a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt +++ b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt @@ -172,7 +172,7 @@ class MPVView @JvmOverloads constructor( MPVLib.setOptionString("http-reconnect", "yes") MPVLib.setOptionString("stream-reconnect", "yes") - MPVLib.setOptionString("demuxer-lavf-o", "live_start_index=0,prefer_x_start=1,http_persistent=0") + MPVLib.setOptionString("demuxer-lavf-o", "live_start_index=0,prefer_x_start=1,http_persistent=1") MPVLib.setOptionString("demuxer-seekable-cache", "yes") MPVLib.setOptionString("force-seekable", "yes") @@ -235,6 +235,10 @@ class MPVView @JvmOverloads constructor( Log.d(TAG, "Loading file: $url") // Reset load event flag for new file hasLoadEventFired = false + + // Re-apply headers before loading to ensure segments/keys use the correct headers + applyHttpHeadersAsOptions() + MPVLib.command(arrayOf("loadfile", url)) } @@ -252,25 +256,39 @@ class MPVView @JvmOverloads constructor( fun setHeaders(headers: Map?) { httpHeaders = headers Log.d(TAG, "Headers set: $headers") + if (isMpvInitialized) { + applyHttpHeadersAsOptions() + } } private fun applyHttpHeadersAsOptions() { - // Always set user-agent (this works reliably) - val userAgent = httpHeaders?.get("User-Agent") + // Find User-Agent (case-insensitive) + val userAgentKey = httpHeaders?.keys?.find { it.equals("User-Agent", ignoreCase = true) } + val userAgent = userAgentKey?.let { httpHeaders?.get(it) } ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" Log.d(TAG, "Setting User-Agent: $userAgent") - MPVLib.setOptionString("user-agent", userAgent) - // Additionally, set other headers via http-header-fields if present - // This is needed for streams that require Referer, Origin, Cookie, etc. + if (isMpvInitialized) { + MPVLib.setPropertyString("user-agent", userAgent) + } else { + MPVLib.setOptionString("user-agent", userAgent) + } + httpHeaders?.let { headers -> - val otherHeaders = headers.filterKeys { it != "User-Agent" } + val otherHeaders = headers.filterKeys { !it.equals("User-Agent", ignoreCase = true) } if (otherHeaders.isNotEmpty()) { - // Format as comma-separated "Key: Value" pairs - val headerString = otherHeaders.map { (key, value) -> "$key: $value" }.joinToString(",") - Log.d(TAG, "Setting additional headers: $headerString") - MPVLib.setOptionString("http-header-fields", headerString) + // Use newline separator for http-header-fields as it's the standard for mpv + val headerString = otherHeaders.map { (key, value) -> "$key: $value" }.joinToString("\n") + Log.d(TAG, "Setting additional headers:\n$headerString") + + if (isMpvInitialized) { + MPVLib.setPropertyString("http-header-fields", headerString) + } else { + MPVLib.setOptionString("http-header-fields", headerString) + } + } else if (isMpvInitialized) { + MPVLib.setPropertyString("http-header-fields", "") } } } diff --git a/android/gradle.properties b/android/gradle.properties index bc4cf849..4e6eb5cb 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -12,6 +12,9 @@ # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m +# Enable Gradle Build Cache +org.gradle.caching=true + # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects diff --git a/assets/rating-icons/mal-icon.png b/assets/rating-icons/mal-icon.png new file mode 100644 index 00000000..d669295f Binary files /dev/null and b/assets/rating-icons/mal-icon.png differ diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt index 2d3f8cae..ec131ade 100644 --- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt +++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt @@ -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(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 } 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 7db96bd3..3bd58165 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 @@ -228,8 +228,7 @@ public class ReactExoplayerView extends FrameLayout implements private ArrayList rootViewChildrenOriginalVisibility = new ArrayList(); /* - * When user is seeking first called is on onPositionDiscontinuity -> - * DISCONTINUITY_REASON_SEEK + * When user is seeking first called is on onPositionDiscontinuity -> DISCONTINUITY_REASON_SEEK * Then we set if to false when playback is back in onIsPlayingChanged -> true */ private boolean isSeeking = false; @@ -299,8 +298,7 @@ public class ReactExoplayerView extends FrameLayout implements lastPos = pos; lastBufferDuration = bufferedDuration; lastDuration = duration; - eventEmitter.onVideoProgress.invoke(pos, bufferedDuration, player.getDuration(), - getPositionInFirstPeriodMsForCurrentWindow(pos)); + eventEmitter.onVideoProgress.invoke(pos, bufferedDuration, player.getDuration(), getPositionInFirstPeriodMsForCurrentWindow(pos)); } } } @@ -318,7 +316,7 @@ public class ReactExoplayerView extends FrameLayout implements public double getPositionInFirstPeriodMsForCurrentWindow(long currentPosition) { Timeline.Window window = new Timeline.Window(); - if (!player.getCurrentTimeline().isEmpty()) { + if(!player.getCurrentTimeline().isEmpty()) { player.getCurrentTimeline().getWindow(player.getCurrentMediaItemIndex(), window); } return window.windowStartTimeMs + currentPosition; @@ -357,9 +355,9 @@ public class ReactExoplayerView extends FrameLayout implements LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); exoPlayerView = new ExoPlayerView(getContext()); - exoPlayerView.addOnLayoutChangeListener( - (View v, int l, int t, int r, int b, int ol, int ot, int or, int ob) -> PictureInPictureUtil - .applySourceRectHint(themedReactContext, pictureInPictureParamsBuilder, exoPlayerView)); + exoPlayerView.addOnLayoutChangeListener( (View v, int l, int t, int r, int b, int ol, int ot, int or, int ob) -> + PictureInPictureUtil.applySourceRectHint(themedReactContext, pictureInPictureParamsBuilder, exoPlayerView) + ); exoPlayerView.setLayoutParams(layoutParams); addView(exoPlayerView, 0, layoutParams); @@ -385,11 +383,9 @@ public class ReactExoplayerView extends FrameLayout implements public void onHostPause() { isInBackground = true; Activity activity = themedReactContext.getCurrentActivity(); - boolean isInPictureInPicture = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null - && activity.isInPictureInPictureMode(); - boolean isInMultiWindowMode = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null - && activity.isInMultiWindowMode(); - if (playInBackground || isInPictureInPicture || isInMultiWindowMode) { + boolean isInPictureInPicture = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null && activity.isInPictureInPictureMode(); + boolean isInMultiWindowMode = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null && activity.isInMultiWindowMode(); + if (playInBackground || isInPictureInPicture || isInMultiWindowMode || enterPictureInPictureOnLeave) { return; } setPlayWhenReady(false); @@ -407,7 +403,7 @@ public class ReactExoplayerView extends FrameLayout implements viewHasDropped = true; } - // BandwidthMeter.EventListener implementation + //BandwidthMeter.EventListener implementation @Override public void onBandwidthSample(int elapsedMs, long bytes, long bitrate) { if (mReportBandwidth) { @@ -415,8 +411,7 @@ public class ReactExoplayerView extends FrameLayout implements eventEmitter.onVideoBandwidthUpdate.invoke(bitrate, 0, 0, null); } else { Format videoFormat = player.getVideoFormat(); - boolean isRotatedContent = videoFormat != null - && (videoFormat.rotationDegrees == 90 || videoFormat.rotationDegrees == 270); + boolean isRotatedContent = videoFormat != null && (videoFormat.rotationDegrees == 90 || videoFormat.rotationDegrees == 270); int width = videoFormat != null ? (isRotatedContent ? videoFormat.height : videoFormat.width) : 0; int height = videoFormat != null ? (isRotatedContent ? videoFormat.width : videoFormat.height) : 0; String trackId = videoFormat != null ? videoFormat.id : null; @@ -431,8 +426,7 @@ public class ReactExoplayerView extends FrameLayout implements * Toggling the visibility of the player control view */ private void togglePlayerControlVisibility() { - if (player == null) - return; + if (player == null) return; if (exoPlayerView.isControllerVisible()) { exoPlayerView.hideController(); } else { @@ -456,8 +450,7 @@ public class ReactExoplayerView extends FrameLayout implements } private void updateControllerConfig() { - if (exoPlayerView == null) - return; + if (exoPlayerView == null) return; exoPlayerView.setControllerShowTimeoutMs(5000); @@ -468,8 +461,7 @@ public class ReactExoplayerView extends FrameLayout implements } private void updateControllerVisibility() { - if (exoPlayerView == null) - return; + if (exoPlayerView == null) return; exoPlayerView.setUseController(controls && !controlsConfig.getHideFullscreen()); } @@ -477,7 +469,7 @@ public class ReactExoplayerView extends FrameLayout implements private void openSettings() { AlertDialog.Builder builder = new AlertDialog.Builder(themedReactContext); builder.setTitle(R.string.settings); - String[] settingsOptions = { themedReactContext.getString(R.string.playback_speed) }; + String[] settingsOptions = {themedReactContext.getString(R.string.playback_speed)}; builder.setItems(settingsOptions, (dialog, which) -> { if (which == 0) { showPlaybackSpeedOptions(); @@ -487,7 +479,7 @@ public class ReactExoplayerView extends FrameLayout implements } private void showPlaybackSpeedOptions() { - String[] speedOptions = { "0.5x", "1.0x", "1.5x", "2.0x" }; + String[] speedOptions = {"0.5x", "1.0x", "1.5x", "2.0x"}; AlertDialog.Builder builder = new AlertDialog.Builder(themedReactContext); builder.setTitle(R.string.select_playback_speed); @@ -505,10 +497,8 @@ public class ReactExoplayerView extends FrameLayout implements speed = 2.0f; break; default: - speed = 1.0f; - ; - } - ; + speed = 1.0f;; + }; setRateModifier(speed); }); builder.show(); @@ -520,30 +510,24 @@ public class ReactExoplayerView extends FrameLayout implements /** * Update the layout + * @param view view needs to update layout * - * @param view view needs to update layout - * - * This is a workaround for the open bug in react-native: ... + * This is a workaround for the open bug in react-native: ... */ private void reLayout(View view) { - if (view == null) - return; + if (view == null) return; view.measure(MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(getMeasuredHeight(), MeasureSpec.EXACTLY)); view.layout(view.getLeft(), view.getTop(), view.getMeasuredWidth(), view.getMeasuredHeight()); } private void refreshControlsStyles() { - if (exoPlayerView == null || player == null || !controls) - return; + if (exoPlayerView == null || player == null || !controls) return; updateControllerVisibility(); } - // Note: The following methods for live content and button visibility are no - // longer needed - // as PlayerView handles controls automatically. Some functionality may need to - // be + // Note: The following methods for live content and button visibility are no longer needed + // as PlayerView handles controls automatically. Some functionality may need to be // reimplemented using PlayerView's APIs if custom behavior is required. private void reLayoutControls() { @@ -580,7 +564,6 @@ public class ReactExoplayerView extends FrameLayout implements private class RNVLoadControl extends DefaultLoadControl { private final int availableHeapInBytes; private final Runtime runtime; - public RNVLoadControl(DefaultAllocator allocator, BufferConfig config) { super(allocator, config.getMinBufferMs() != BufferConfig.Companion.getBufferConfigPropUnsetInt() @@ -591,7 +574,7 @@ public class ReactExoplayerView extends FrameLayout implements : DefaultLoadControl.DEFAULT_MAX_BUFFER_MS, config.getBufferForPlaybackMs() != BufferConfig.Companion.getBufferConfigPropUnsetInt() ? config.getBufferForPlaybackMs() - : DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, + : DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS , config.getBufferForPlaybackAfterRebufferMs() != BufferConfig.Companion.getBufferConfigPropUnsetInt() ? config.getBufferForPlaybackAfterRebufferMs() : DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, @@ -602,12 +585,10 @@ public class ReactExoplayerView extends FrameLayout implements : DefaultLoadControl.DEFAULT_BACK_BUFFER_DURATION_MS, DefaultLoadControl.DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME); runtime = Runtime.getRuntime(); - ActivityManager activityManager = (ActivityManager) themedReactContext - .getSystemService(ThemedReactContext.ACTIVITY_SERVICE); - double maxHeap = config.getMaxHeapAllocationPercent() != BufferConfig.Companion - .getBufferConfigPropUnsetDouble() - ? config.getMaxHeapAllocationPercent() - : DEFAULT_MAX_HEAP_ALLOCATION_PERCENT; + ActivityManager activityManager = (ActivityManager) themedReactContext.getSystemService(ThemedReactContext.ACTIVITY_SERVICE); + double maxHeap = config.getMaxHeapAllocationPercent() != BufferConfig.Companion.getBufferConfigPropUnsetDouble() + ? config.getMaxHeapAllocationPercent() + : DEFAULT_MAX_HEAP_ALLOCATION_PERCENT; availableHeapInBytes = (int) Math.floor(activityManager.getMemoryClass() * maxHeap * 1024 * 1024); } @@ -625,15 +606,13 @@ public class ReactExoplayerView extends FrameLayout implements } long usedMemory = runtime.totalMemory() - runtime.freeMemory(); long freeMemory = runtime.maxMemory() - usedMemory; - double minBufferMemoryReservePercent = source.getBufferConfig() - .getMinBufferMemoryReservePercent() != BufferConfig.Companion.getBufferConfigPropUnsetDouble() - ? source.getBufferConfig().getMinBufferMemoryReservePercent() - : ReactExoplayerView.DEFAULT_MIN_BUFFER_MEMORY_RESERVE; + double minBufferMemoryReservePercent = source.getBufferConfig().getMinBufferMemoryReservePercent() != BufferConfig.Companion.getBufferConfigPropUnsetDouble() + ? source.getBufferConfig().getMinBufferMemoryReservePercent() + : ReactExoplayerView.DEFAULT_MIN_BUFFER_MEMORY_RESERVE; long reserveMemory = (long) minBufferMemoryReservePercent * runtime.maxMemory(); long bufferedMs = bufferedDurationUs / (long) 1000; if (reserveMemory > freeMemory && bufferedMs > 2000) { - // We don't have enough memory in reserve so we stop buffering to allow other - // components to use it instead + // We don't have enough memory in reserve so we stop buffering to allow other components to use it instead return false; } if (runtime.freeMemory() == 0) { @@ -666,13 +645,13 @@ public class ReactExoplayerView extends FrameLayout implements // Initialize core configuration and listeners initializePlayerCore(self); pipListenerUnsubscribe = PictureInPictureUtil.addLifecycleEventListener(themedReactContext, this); - PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, - this.enterPictureInPictureOnLeave); + PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, this.enterPictureInPictureOnLeave); } if (!source.isLocalAssetFile() && !source.isAsset() && source.getBufferConfig().getCacheSize() > 0) { RNVSimpleCache.INSTANCE.setSimpleCache( this.getContext(), - source.getBufferConfig().getCacheSize()); + source.getBufferConfig().getCacheSize() + ); useCache = true; } else { useCache = false; @@ -680,8 +659,7 @@ public class ReactExoplayerView extends FrameLayout implements if (playerNeedsSource) { // Will force display of shutter view if needed exoPlayerView.invalidateAspectRatio(); - // DRM session manager creation must be done on a different thread to prevent - // crashes so we start a new thread + // DRM session manager creation must be done on a different thread to prevent crashes so we start a new thread ExecutorService es = Executors.newSingleThreadExecutor(); es.execute(() -> { // DRM initialization must run on a different thread @@ -690,8 +668,7 @@ public class ReactExoplayerView extends FrameLayout implements } if (activity == null) { DebugLog.e(TAG, "Failed to initialize Player!, null activity"); - eventEmitter.onVideoError.invoke("Failed to initialize Player!", - new Exception("Current Activity is null!"), "1001"); + eventEmitter.onVideoError.invoke("Failed to initialize Player!", new Exception("Current Activity is null!"), "1001"); return; } @@ -744,7 +721,8 @@ public class ReactExoplayerView extends FrameLayout implements DefaultAllocator allocator = new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE); RNVLoadControl loadControl = new RNVLoadControl( allocator, - source.getBufferConfig()); + source.getBufferConfig() + ); long initialBitrate = source.getBufferConfig().getInitialBitrate(); if (initialBitrate > 0) { @@ -752,10 +730,11 @@ public class ReactExoplayerView extends FrameLayout implements this.bandwidthMeter = config.getBandwidthMeter(); } - DefaultRenderersFactory renderersFactory = new DefaultRenderersFactory(getContext()) - .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER) - .setEnableDecoderFallback(true) - .forceEnableMediaCodecAsynchronousQueueing(); + DefaultRenderersFactory renderersFactory = + new DefaultRenderersFactory(getContext()) + .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF) + .setEnableDecoderFallback(true) + .forceEnableMediaCodecAsynchronousQueueing(); DefaultMediaSourceFactory mediaSourceFactory; @@ -764,13 +743,11 @@ public class ReactExoplayerView extends FrameLayout implements } else { mediaSourceFactory = new DefaultMediaSourceFactory(mediaDataSourceFactory); - mediaSourceFactory.setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, - exoPlayerView.getPlayerView()); + mediaSourceFactory.setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, exoPlayerView.getPlayerView()); } if (useCache && !disableCache) { - mediaSourceFactory - .setDataSourceFactory(RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true))); + mediaSourceFactory.setDataSourceFactory(RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true))); } player = new ExoPlayer.Builder(getContext(), renderersFactory) @@ -795,7 +772,7 @@ public class ReactExoplayerView extends FrameLayout implements player.setPlaybackParameters(params); changeAudioOutput(this.audioOutput); - if (showNotificationControls) { + if(showNotificationControls) { setupPlaybackService(); } } @@ -807,7 +784,8 @@ public class ReactExoplayerView extends FrameLayout implements Uri adTagUrl = adProps.getAdTagUrl(); if (adTagUrl != null) { // Create an AdsLoader. - ImaAdsLoader.Builder imaLoaderBuilder = new ImaAdsLoader.Builder(themedReactContext) + ImaAdsLoader.Builder imaLoaderBuilder = new ImaAdsLoader + .Builder(themedReactContext) .setAdEventListener(this) .setAdErrorListener(this); @@ -839,8 +817,7 @@ public class ReactExoplayerView extends FrameLayout implements } try { - // First check if there's a custom DRM manager registered through the plugin - // system + // First check if there's a custom DRM manager registered through the plugin system DRMManagerSpec drmManager = ReactNativeVideoManager.Companion.getInstance().getDRMManager(); if (drmManager == null) { // If no custom manager is registered, use the default implementation @@ -849,13 +826,11 @@ public class ReactExoplayerView extends FrameLayout implements DrmSessionManager drmSessionManager = drmManager.buildDrmSessionManager(uuid, drmProps); if (drmSessionManager == null) { - eventEmitter.onVideoError.invoke("Failed to build DRM session manager", - new Exception("DRM session manager is null"), "3007"); + eventEmitter.onVideoError.invoke("Failed to build DRM session manager", new Exception("DRM session manager is null"), "3007"); } // Allow plugins to override the DrmSessionManager - DrmSessionManager overriddenManager = ReactNativeVideoManager.Companion.getInstance() - .overrideDrmSessionManager(source, drmSessionManager); + DrmSessionManager overriddenManager = ReactNativeVideoManager.Companion.getInstance().overrideDrmSessionManager(source, drmSessionManager); return overriddenManager != null ? overriddenManager : drmSessionManager; } catch (UnsupportedDrmException ex) { // Unsupported DRM exceptions are handled by the calling method @@ -878,8 +853,7 @@ public class ReactExoplayerView extends FrameLayout implements } /// init DRM DrmSessionManager drmSessionManager = initializePlayerDrm(); - if (drmSessionManager == null && runningSource.getDrmProps() != null - && runningSource.getDrmProps().getDrmType() != null) { + if (drmSessionManager == null && runningSource.getDrmProps() != null && runningSource.getDrmProps().getDrmType() != null) { // Failed to initialize DRM session manager - cannot continue DebugLog.e(TAG, "Failed to initialize DRM Session Manager Framework!"); return; @@ -936,8 +910,7 @@ public class ReactExoplayerView extends FrameLayout implements } catch (UnsupportedDrmException e) { int errorStringId = Util.SDK_INT < 18 ? R.string.error_drm_not_supported : (e.reason == UnsupportedDrmException.REASON_UNSUPPORTED_SCHEME - ? R.string.error_drm_unsupported_scheme - : R.string.error_drm_unknown); + ? R.string.error_drm_unsupported_scheme : R.string.error_drm_unknown); eventEmitter.onVideoError.invoke(getResources().getString(errorStringId), e, "3003"); } } @@ -982,8 +955,7 @@ public class ReactExoplayerView extends FrameLayout implements if (playbackServiceBinder != null) { playbackServiceBinder.getService().unregisterPlayer(player); } - } catch (Exception ignored) { - } + } catch (Exception ignored) {} playbackServiceBinder = null; } @@ -1015,22 +987,21 @@ public class ReactExoplayerView extends FrameLayout implements private void cleanupPlaybackService() { try { - if (player != null && playbackServiceBinder != null) { + if(player != null && playbackServiceBinder != null) { playbackServiceBinder.getService().unregisterPlayer(player); } playbackServiceBinder = null; - if (playbackServiceConnection != null) { + if(playbackServiceConnection != null) { themedReactContext.unbindService(playbackServiceConnection); } - } catch (Exception e) { + } catch(Exception e) { DebugLog.w(TAG, "Cloud not cleanup playback service"); } } - private MediaSource buildMediaSource(Uri uri, String overrideExtension, DrmSessionManager drmSessionManager, - long cropStartMs, long cropEndMs) { + private MediaSource buildMediaSource(Uri uri, String overrideExtension, DrmSessionManager drmSessionManager, long cropStartMs, long cropEndMs) { if (uri == null) { throw new IllegalStateException("Invalid video uri"); } @@ -1062,12 +1033,12 @@ public class ReactExoplayerView extends FrameLayout implements Uri adTagUrl = source.getAdsProps().getAdTagUrl(); if (adTagUrl != null) { mediaItemBuilder.setAdsConfiguration( - new MediaItem.AdsConfiguration.Builder(adTagUrl).build()); + new MediaItem.AdsConfiguration.Builder(adTagUrl).build() + ); } } - MediaItem.LiveConfiguration.Builder liveConfiguration = ConfigurationUtils - .getLiveConfiguration(source.getBufferConfig()); + MediaItem.LiveConfiguration.Builder liveConfiguration = ConfigurationUtils.getLiveConfiguration(source.getBufferConfig()); mediaItemBuilder.setLiveConfiguration(liveConfiguration.build()); MediaSource.Factory mediaSourceFactory; @@ -1079,26 +1050,29 @@ public class ReactExoplayerView extends FrameLayout implements drmProvider = new DefaultDrmSessionManagerProvider(); } + switch (type) { case CONTENT_TYPE_SS: - if (!BuildConfig.USE_EXOPLAYER_SMOOTH_STREAMING) { + if(!BuildConfig.USE_EXOPLAYER_SMOOTH_STREAMING) { DebugLog.e("Exo Player Exception", "Smooth Streaming is not enabled!"); throw new IllegalStateException("Smooth Streaming is not enabled!"); } mediaSourceFactory = new SsMediaSource.Factory( new DefaultSsChunkSource.Factory(mediaDataSourceFactory), - buildDataSourceFactory(false)); + buildDataSourceFactory(false) + ); break; case CONTENT_TYPE_DASH: - if (!BuildConfig.USE_EXOPLAYER_DASH) { + if(!BuildConfig.USE_EXOPLAYER_DASH) { DebugLog.e("Exo Player Exception", "DASH is not enabled!"); throw new IllegalStateException("DASH is not enabled!"); } mediaSourceFactory = new DashMediaSource.Factory( new DefaultDashChunkSource.Factory(mediaDataSourceFactory), - buildDataSourceFactory(false)); + buildDataSourceFactory(false) + ); break; case CONTENT_TYPE_HLS: if (!BuildConfig.USE_EXOPLAYER_HLS) { @@ -1113,14 +1087,13 @@ public class ReactExoplayerView extends FrameLayout implements } mediaSourceFactory = new HlsMediaSource.Factory( - dataSourceFactory) - .setAllowChunklessPreparation(source.getTextTracksAllowChunklessPreparation()); + dataSourceFactory + ).setAllowChunklessPreparation(source.getTextTracksAllowChunklessPreparation()); break; case CONTENT_TYPE_OTHER: if ("asset".equals(uri.getScheme())) { try { - DataSource.Factory assetDataSourceFactory = DataSourceUtil - .buildAssetDataSourceFactory(themedReactContext, uri); + DataSource.Factory assetDataSourceFactory = DataSourceUtil.buildAssetDataSourceFactory(themedReactContext, uri); mediaSourceFactory = new ProgressiveMediaSource.Factory(assetDataSourceFactory); } catch (Exception e) { throw new IllegalStateException("cannot open input file:" + uri); @@ -1128,10 +1101,12 @@ public class ReactExoplayerView extends FrameLayout implements } else if ("file".equals(uri.getScheme()) || !useCache) { mediaSourceFactory = new ProgressiveMediaSource.Factory( - mediaDataSourceFactory); + mediaDataSourceFactory + ); } else { mediaSourceFactory = new ProgressiveMediaSource.Factory( - RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true))); + RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true)) + ); } break; @@ -1150,19 +1125,20 @@ public class ReactExoplayerView extends FrameLayout implements if (cmcdConfigurationFactory != null) { mediaSourceFactory = mediaSourceFactory.setCmcdConfigurationFactory( - cmcdConfigurationFactory::createCmcdConfiguration); + cmcdConfigurationFactory::createCmcdConfiguration + ); } mediaSourceFactory = Objects.requireNonNullElse( ReactNativeVideoManager.Companion.getInstance() .overrideMediaSourceFactory(source, mediaSourceFactory, mediaDataSourceFactory), - mediaSourceFactory); + mediaSourceFactory + ); mediaItemBuilder.setStreamKeys(streamKeys); @Nullable - final MediaItem.Builder overridenMediaItemBuilder = ReactNativeVideoManager.Companion.getInstance() - .overrideMediaItemBuilder(source, mediaItemBuilder); + final MediaItem.Builder overridenMediaItemBuilder = ReactNativeVideoManager.Companion.getInstance().overrideMediaItemBuilder(source, mediaItemBuilder); MediaItem mediaItem = overridenMediaItemBuilder != null ? overridenMediaItemBuilder.build() @@ -1171,7 +1147,8 @@ public class ReactExoplayerView extends FrameLayout implements MediaSource mediaSource = mediaSourceFactory .setDrmSessionManagerProvider(drmProvider) .setLoadErrorHandlingPolicy( - config.buildLoadErrorHandlingPolicy(source.getMinLoadRetryCount())) + config.buildLoadErrorHandlingPolicy(source.getMinLoadRetryCount()) + ) .createMediaSource(mediaItem); if (cropStartMs >= 0 && cropEndMs >= 0) { @@ -1206,8 +1183,7 @@ public class ReactExoplayerView extends FrameLayout implements } } - MediaItem.SubtitleConfiguration.Builder configBuilder = new MediaItem.SubtitleConfiguration.Builder( - track.getUri()) + MediaItem.SubtitleConfiguration.Builder configBuilder = new MediaItem.SubtitleConfiguration.Builder(track.getUri()) .setId(trackId) .setMimeType(track.getType()) .setLabel(label) @@ -1218,8 +1194,7 @@ public class ReactExoplayerView extends FrameLayout implements configBuilder.setLanguage(track.getLanguage()); } - // Set selection flags - make first track default if no specific track is - // selected + // Set selection flags - make first track default if no specific track is selected if (trackIndex == 0 && (textTrackType == null || "disabled".equals(textTrackType))) { configBuilder.setSelectionFlags(C.SELECTION_FLAG_DEFAULT); } else { @@ -1229,12 +1204,10 @@ public class ReactExoplayerView extends FrameLayout implements MediaItem.SubtitleConfiguration subtitleConfiguration = configBuilder.build(); subtitleConfigurations.add(subtitleConfiguration); - DebugLog.d(TAG, - "Created subtitle configuration: " + trackId + " - " + label + " (" + track.getType() + ")"); + DebugLog.d(TAG, "Created subtitle configuration: " + trackId + " - " + label + " (" + track.getType() + ")"); trackIndex++; } catch (Exception e) { - DebugLog.e(TAG, - "Error creating SubtitleConfiguration for URI " + track.getUri() + ": " + e.getMessage()); + DebugLog.e(TAG, "Error creating SubtitleConfiguration for URI " + track.getUri() + ": " + e.getMessage()); } } @@ -1247,7 +1220,7 @@ public class ReactExoplayerView extends FrameLayout implements private void releasePlayer() { if (player != null) { - if (playbackServiceBinder != null) { + if(playbackServiceBinder != null) { playbackServiceBinder.getService().unregisterPlayer(player); themedReactContext.unbindService(playbackServiceConnection); } @@ -1303,8 +1276,7 @@ public class ReactExoplayerView extends FrameLayout implements case AudioManager.AUDIOFOCUS_LOSS: view.hasAudioFocus = false; view.eventEmitter.onAudioFocusChanged.invoke(false); - // FIXME this pause can cause issue if content doesn't have pause capability - // (can happen on live channel) + // FIXME this pause can cause issue if content doesn't have pause capability (can happen on live channel) if (activity != null) { activity.runOnUiThread(view::pausePlayback); } @@ -1325,12 +1297,16 @@ public class ReactExoplayerView extends FrameLayout implements if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) { // Lower the volume if (!view.muted) { - activity.runOnUiThread(() -> view.player.setVolume(view.audioVolume * 0.8f)); + activity.runOnUiThread(() -> + view.player.setVolume(view.audioVolume * 0.8f) + ); } } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) { // Raise it back to normal if (!view.muted) { - activity.runOnUiThread(() -> view.player.setVolume(view.audioVolume * 1)); + activity.runOnUiThread(() -> + view.player.setVolume(view.audioVolume * 1) + ); } } } @@ -1403,8 +1379,7 @@ public class ReactExoplayerView extends FrameLayout implements /** * Returns a new DataSource factory. * - * @param useBandwidthMeter Whether to set {@link #bandwidthMeter} as a listener - * to the new + * @param useBandwidthMeter Whether to set {@link #bandwidthMeter} as a listener to the new * DataSource factory. * @return A new DataSource factory. */ @@ -1416,14 +1391,12 @@ public class ReactExoplayerView extends FrameLayout implements /** * Returns a new HttpDataSource factory. * - * @param useBandwidthMeter Whether to set {@link #bandwidthMeter} as a listener - * to the new - * DataSource factory. + * @param useBandwidthMeter Whether to set {@link #bandwidthMeter} as a listener to the new + * DataSource factory. * @return A new HttpDataSource factory. */ private HttpDataSource.Factory buildHttpDataSourceFactory(boolean useBandwidthMeter) { - return DataSourceUtil.getDefaultHttpDataSourceFactory(this.themedReactContext, - useBandwidthMeter ? bandwidthMeter : null, source.getHeaders()); + return DataSourceUtil.getDefaultHttpDataSourceFactory(this.themedReactContext, useBandwidthMeter ? bandwidthMeter : null, source.getHeaders()); } // AudioBecomingNoisyListener implementation @@ -1440,13 +1413,11 @@ public class ReactExoplayerView extends FrameLayout implements @Override public void onEvents(@NonNull Player player, Player.Events events) { - if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED) - || events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) { + if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED) || events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) { int playbackState = player.getPlaybackState(); boolean playWhenReady = player.getPlayWhenReady(); String text = "onStateChanged: playWhenReady=" + playWhenReady + ", playbackState="; - eventEmitter.onPlaybackRateChange - .invoke(playWhenReady && playbackState == ExoPlayer.STATE_READY ? 1.0f : 0.0f); + eventEmitter.onPlaybackRateChange.invoke(playWhenReady && playbackState == ExoPlayer.STATE_READY ? 1.0f : 0.0f); switch (playbackState) { case Player.STATE_IDLE: text += "idle"; @@ -1503,11 +1474,9 @@ public class ReactExoplayerView extends FrameLayout implements } /** - * The progress message handler will duplicate recursions of the - * onProgressMessage handler - * on change of player state from any state to STATE_READY with playWhenReady is - * true (when - * the video is not paused). This clears all existing messages. + * The progress message handler will duplicate recursions of the onProgressMessage handler + * on change of player state from any state to STATE_READY with playWhenReady is true (when + * the video is not paused). This clears all existing messages. */ private void clearProgressMessageHandler() { progressHandler.removeMessages(SHOW_PROGRESS); @@ -1526,8 +1495,7 @@ public class ReactExoplayerView extends FrameLayout implements setSelectedTextTrack(textTrackType, textTrackValue); } Format videoFormat = player.getVideoFormat(); - boolean isRotatedContent = videoFormat != null - && (videoFormat.rotationDegrees == 90 || videoFormat.rotationDegrees == 270); + boolean isRotatedContent = videoFormat != null && (videoFormat.rotationDegrees == 90 || videoFormat.rotationDegrees == 270); int width = videoFormat != null ? (isRotatedContent ? videoFormat.height : videoFormat.width) : 0; int height = videoFormat != null ? (isRotatedContent ? videoFormat.width : videoFormat.height) : 0; String trackId = videoFormat != null ? videoFormat.id : null; @@ -1536,19 +1504,18 @@ public class ReactExoplayerView extends FrameLayout implements long duration = player.getDuration(); long currentPosition = player.getCurrentPosition(); ArrayList audioTracks = getAudioTrackInfo(); - ArrayList textTracks = getTextTrackInfo(); + ArrayList textTracks = getTextTrackInfo(); if (source.getContentStartTime() != -1) { ExecutorService es = Executors.newSingleThreadExecutor(); es.execute(() -> { - // To prevent ANRs caused by getVideoTrackInfo we run this on a different thread - // and notify the player only when we're done + // To prevent ANRs caused by getVideoTrackInfo we run this on a different thread and notify the player only when we're done ArrayList videoTracks = getVideoTrackInfoFromManifest(); if (videoTracks != null) { isUsingContentResolution = true; } eventEmitter.onVideoLoad.invoke(duration, currentPosition, width, height, - audioTracks, textTracks, videoTracks, trackId); + audioTracks, textTracks, videoTracks, trackId ); updateSubtitleButtonVisibility(); }); @@ -1566,9 +1533,9 @@ public class ReactExoplayerView extends FrameLayout implements } private static boolean isTrackSelected(TrackSelection selection, TrackGroup group, - int trackIndex) { + int trackIndex){ return selection != null && selection.getTrackGroup() == group - && selection.indexOf(trackIndex) != C.INDEX_UNSET; + && selection.indexOf( trackIndex ) != C.INDEX_UNSET; } private ArrayList getAudioTrackInfo() { @@ -1586,6 +1553,7 @@ public class ReactExoplayerView extends FrameLayout implements TrackSelectionArray selectionArray = player.getCurrentTrackSelections(); TrackSelection selection = selectionArray.get(C.TRACK_TYPE_AUDIO); + for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) { TrackGroup group = groups.get(groupIndex); Format format = group.getFormat(0); @@ -1599,29 +1567,6 @@ 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); } @@ -1634,8 +1579,7 @@ public class ReactExoplayerView extends FrameLayout implements videoTrack.setHeight(format.height == Format.NO_VALUE ? 0 : format.height); videoTrack.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate); videoTrack.setRotation(format.rotationDegrees); - if (format.codecs != null) - videoTrack.setCodecs(format.codecs); + if (format.codecs != null) videoTrack.setCodecs(format.codecs); videoTrack.setTrackId(format.id == null ? String.valueOf(trackIndex) : format.id); videoTrack.setIndex(trackIndex); return videoTrack; @@ -1672,8 +1616,7 @@ public class ReactExoplayerView extends FrameLayout implements return this.getVideoTrackInfoFromManifest(0); } - // We need retry count to in case where minefest request fails from poor network - // conditions + // We need retry count to in case where minefest request fails from poor network conditions @WorkerThread private ArrayList getVideoTrackInfoFromManifest(int retryCount) { ExecutorService es = Executors.newSingleThreadExecutor(); @@ -1688,20 +1631,18 @@ public class ReactExoplayerView extends FrameLayout implements public ArrayList call() { ArrayList videoTracks = new ArrayList<>(); - try { + try { DashManifest manifest = DashUtil.loadManifest(this.ds, this.uri); int periodCount = manifest.getPeriodCount(); for (int i = 0; i < periodCount; i++) { Period period = manifest.getPeriod(i); - for (int adaptationIndex = 0; adaptationIndex < period.adaptationSets - .size(); adaptationIndex++) { + for (int adaptationIndex = 0; adaptationIndex < period.adaptationSets.size(); adaptationIndex++) { AdaptationSet adaptation = period.adaptationSets.get(adaptationIndex); if (adaptation.type != C.TRACK_TYPE_VIDEO) { continue; } boolean hasFoundContentPeriod = false; - for (int representationIndex = 0; representationIndex < adaptation.representations - .size(); representationIndex++) { + for (int representationIndex = 0; representationIndex < adaptation.representations.size(); representationIndex++) { Representation representation = adaptation.representations.get(representationIndex); Format format = representation.format; if (isFormatSupported(format)) { @@ -1709,8 +1650,7 @@ public class ReactExoplayerView extends FrameLayout implements break; } hasFoundContentPeriod = true; - VideoTrack videoTrack = exoplayerVideoTrackToGenericVideoTrack(format, - representationIndex); + VideoTrack videoTrack = exoplayerVideoTrackToGenericVideoTrack(format, representationIndex); videoTracks.add(videoTrack); } } @@ -1740,16 +1680,12 @@ public class ReactExoplayerView extends FrameLayout implements return null; } - private Track exoplayerTrackToGenericTrack(Format format, int trackIndex, TrackSelection selection, - TrackGroup group) { + private Track exoplayerTrackToGenericTrack(Format format, int trackIndex, TrackSelection selection, TrackGroup group) { Track track = new Track(); track.setIndex(trackIndex); - if (format.sampleMimeType != null) - track.setMimeType(format.sampleMimeType); - if (format.language != null) - track.setLanguage(format.language); - if (format.label != null) - track.setTitle(format.label); + if (format.sampleMimeType != null) track.setMimeType(format.sampleMimeType); + if (format.language != null) track.setLanguage(format.language); + if (format.label != null) track.setTitle(format.label); track.setSelected(isTrackSelected(selection, group, trackIndex)); return track; } @@ -1817,28 +1753,9 @@ public class ReactExoplayerView extends FrameLayout implements Track track = new Track(); track.setIndex(groupIndex); track.setLanguage(format.language != null ? format.language : "unknown"); - 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.setTitle(format.label != null ? format.label : "Track " + (groupIndex + 1)); track.setSelected(false); // Don't report selection status - let PlayerView handle it - if (format.sampleMimeType != null) - track.setMimeType(format.sampleMimeType); + if (format.sampleMimeType != null) track.setMimeType(format.sampleMimeType); track.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate); tracks.add(track); @@ -1869,10 +1786,8 @@ public class ReactExoplayerView extends FrameLayout implements Track textTrack = new Track(); textTrack.setIndex(textTracks.size()); - if (format.sampleMimeType != null) - textTrack.setMimeType(format.sampleMimeType); - if (format.language != null) - textTrack.setLanguage(format.language); + if (format.sampleMimeType != null) textTrack.setMimeType(format.sampleMimeType); + if (format.language != null) textTrack.setLanguage(format.language); boolean isExternal = format.id != null && format.id.startsWith("external-subtitle-"); @@ -1906,34 +1821,28 @@ public class ReactExoplayerView extends FrameLayout implements } @Override - public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition, - @NonNull Player.PositionInfo newPosition, @Player.DiscontinuityReason int reason) { + public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition, @NonNull Player.PositionInfo newPosition, @Player.DiscontinuityReason int reason) { if (reason == Player.DISCONTINUITY_REASON_SEEK) { isSeeking = true; seekPosition = newPosition.positionMs; if (isUsingContentResolution) { - // We need to update the selected track to make sure that it still matches user - // selection if track list has changed in this period + // We need to update the selected track to make sure that it still matches user selection if track list has changed in this period setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue); } } if (playerNeedsSource) { - // This will only occur if the user has performed a seek whilst in the error - // state. Update the - // resume position so that if the user then retries, playback will resume from - // the position to + // This will only occur if the user has performed a seek whilst in the error state. Update the + // resume position so that if the user then retries, playback will resume from the position to // which they seeked. updateResumePosition(); } if (isUsingContentResolution) { - // Discontinuity events might have a different track list so we update the - // selected track + // Discontinuity events might have a different track list so we update the selected track setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue); selectTrackWhenReady = true; } - // When repeat is turned on, reaching the end of the video will not cause a - // state change + // When repeat is turned on, reaching the end of the video will not cause a state change // so we need to explicitly detect it. if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION && player.getRepeatMode() == Player.REPEAT_MODE_ONE) { @@ -1981,17 +1890,15 @@ public class ReactExoplayerView extends FrameLayout implements updateSubtitleButtonVisibility(); } + private boolean hasBuiltInTextTracks() { - if (player == null || trackSelector == null) - return false; + if (player == null || trackSelector == null) return false; MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); - if (info == null) - return false; + if (info == null) return false; int textRendererIndex = getTrackRendererIndex(C.TRACK_TYPE_TEXT); - if (textRendererIndex == C.INDEX_UNSET) - return false; + if (textRendererIndex == C.INDEX_UNSET) return false; TrackGroupArray groups = info.getTrackGroups(textRendererIndex); @@ -2011,12 +1918,11 @@ public class ReactExoplayerView extends FrameLayout implements } private void updateSubtitleButtonVisibility() { - if (exoPlayerView == null) - return; + if (exoPlayerView == null) return; boolean hasTextTracks = (source.getSideLoadedTextTracks() != null && - !source.getSideLoadedTextTracks().getTracks().isEmpty()) || - hasBuiltInTextTracks(); + !source.getSideLoadedTextTracks().getTracks().isEmpty()) || + hasBuiltInTextTracks(); exoPlayerView.setShowSubtitleButton(hasTextTracks); } @@ -2036,8 +1942,7 @@ public class ReactExoplayerView extends FrameLayout implements if (isPlaying && isSeeking) { eventEmitter.onVideoSeek.invoke(player.getCurrentPosition(), seekPosition); } - PictureInPictureUtil.applyPlayingStatus(themedReactContext, pictureInPictureParamsBuilder, - pictureInPictureReceiver, !isPlaying); + PictureInPictureUtil.applyPlayingStatus(themedReactContext, pictureInPictureParamsBuilder, pictureInPictureReceiver, !isPlaying); eventEmitter.onVideoPlaybackStateChanged.invoke(isPlaying, isSeeking); if (isPlaying) { @@ -2049,15 +1954,14 @@ public class ReactExoplayerView extends FrameLayout implements public void onPlayerError(@NonNull PlaybackException e) { String errorString = "ExoPlaybackException: " + PlaybackException.getErrorCodeName(e.errorCode); String errorCode = "2" + e.errorCode; - switch (e.errorCode) { + switch(e.errorCode) { case PlaybackException.ERROR_CODE_DRM_DEVICE_REVOKED: case PlaybackException.ERROR_CODE_DRM_LICENSE_ACQUISITION_FAILED: case PlaybackException.ERROR_CODE_DRM_PROVISIONING_FAILED: case PlaybackException.ERROR_CODE_DRM_SYSTEM_ERROR: case PlaybackException.ERROR_CODE_DRM_UNSPECIFIED: if (!hasDrmFailed) { - // When DRM fails to reach the app level certificate server it will fail with a - // source error so we assume that it is DRM related and try one more time + // When DRM fails to reach the app level certificate server it will fail with a source error so we assume that it is DRM related and try one more time hasDrmFailed = true; playerNeedsSource = true; updateResumePosition(); @@ -2139,16 +2043,14 @@ public class ReactExoplayerView extends FrameLayout implements boolean isSourceEqual = source.isEquals(this.source); hasDrmFailed = false; this.source = source; - final DataSource.Factory tmpMediaDataSourceFactory = DataSourceUtil.getDefaultDataSourceFactory( - this.themedReactContext, bandwidthMeter, - source.getHeaders()); + final DataSource.Factory tmpMediaDataSourceFactory = + DataSourceUtil.getDefaultDataSourceFactory(this.themedReactContext, bandwidthMeter, + source.getHeaders()); @Nullable - final DataSource.Factory overriddenMediaDataSourceFactory = ReactNativeVideoManager.Companion.getInstance() - .overrideMediaDataSourceFactory(source, tmpMediaDataSourceFactory); + final DataSource.Factory overriddenMediaDataSourceFactory = ReactNativeVideoManager.Companion.getInstance().overrideMediaDataSourceFactory(source, tmpMediaDataSourceFactory); - this.mediaDataSourceFactory = Objects.requireNonNullElse(overriddenMediaDataSourceFactory, - tmpMediaDataSourceFactory); + this.mediaDataSourceFactory = Objects.requireNonNullElse(overriddenMediaDataSourceFactory, tmpMediaDataSourceFactory); if (source.getCmcdProps() != null) { CMCDConfig cmcdConfig = new CMCDConfig(source.getCmcdProps()); @@ -2167,7 +2069,6 @@ public class ReactExoplayerView extends FrameLayout implements clearSrc(); } } - public void clearSrc() { if (source.getUri() != null) { if (player != null) { @@ -2216,8 +2117,7 @@ public class ReactExoplayerView extends FrameLayout implements } public void disableTrack(int rendererIndex) { - if (trackSelector == null) - return; + if (trackSelector == null) return; DefaultTrackSelector.Parameters disableParameters = trackSelector.getParameters() .buildUpon() @@ -2227,8 +2127,7 @@ public class ReactExoplayerView extends FrameLayout implements } private void selectTextTrackInternal(String type, String value) { - if (player == null || trackSelector == null) - return; + if (player == null || trackSelector == null) return; DebugLog.d(TAG, "selectTextTrackInternal: type=" + type + ", value=" + value); @@ -2248,11 +2147,6 @@ public class ReactExoplayerView extends FrameLayout implements TrackGroupArray groups = info.getTrackGroups(textRendererIndex); boolean trackFound = false; - // react-native-video uses a flattened `textTracks` list on the JS side. - // For HLS/DASH, each TrackGroup often contains a single track at index 0, - // so comparing against `trackIndex` alone makes only the first subtitle selectable. - int flattenedIndex = 0; - for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { TrackGroup group = groups.get(groupIndex); for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { @@ -2265,28 +2159,25 @@ public class ReactExoplayerView extends FrameLayout implements isMatch = true; } else if ("index".equals(type)) { int targetIndex = ReactBridgeUtils.safeParseInt(value, -1); - if (targetIndex == flattenedIndex) { + if (targetIndex == trackIndex) { isMatch = true; } } - flattenedIndex++; - if (isMatch) { TrackSelectionOverride override = new TrackSelectionOverride(group, - java.util.Arrays.asList(trackIndex)); + java.util.Arrays.asList(trackIndex)); parametersBuilder.addOverride(override); trackFound = true; break; } } - if (trackFound) - break; + if (trackFound) break; } if (!trackFound) { DebugLog.w(TAG, "Text track not found for type=" + type + ", value=" + value + - ". Keeping current selection."); + ". Keeping current selection."); } } } @@ -2307,8 +2198,7 @@ public class ReactExoplayerView extends FrameLayout implements } public void setSelectedTrack(int trackType, String type, String value) { - if (player == null || trackSelector == null) - return; + if (player == null || trackSelector == null) return; if (controls) { return; @@ -2382,11 +2272,9 @@ public class ReactExoplayerView extends FrameLayout implements usingExactMatch = true; break; } else if (isUsingContentResolution) { - // When using content resolution rather than ads, we need to try and find the - // closest match if there is no exact match + // When using content resolution rather than ads, we need to try and find the closest match if there is no exact match if (closestFormat != null) { - if ((format.bitrate > closestFormat.bitrate || format.height > closestFormat.height) - && format.height < height) { + if ((format.bitrate > closestFormat.bitrate || format.height > closestFormat.height) && format.height < height) { // Higher quality match closestFormat = format; closestTrackIndex = j; @@ -2397,8 +2285,7 @@ public class ReactExoplayerView extends FrameLayout implements } } } - // This is a fallback if the new period contains only higher resolutions than - // the user has selected + // This is a fallback if the new period contains only higher resolutions than the user has selected if (closestFormat == null && isUsingContentResolution && !usingExactMatch) { // No close match found - so we pick the lowest quality int minHeight = Integer.MAX_VALUE; @@ -2421,8 +2308,8 @@ public class ReactExoplayerView extends FrameLayout implements } } else if (trackType == C.TRACK_TYPE_TEXT && Util.SDK_INT > 18) { // Text default // Use system settings if possible - CaptioningManager captioningManager = (CaptioningManager) themedReactContext - .getSystemService(Context.CAPTIONING_SERVICE); + CaptioningManager captioningManager + = (CaptioningManager)themedReactContext.getSystemService(Context.CAPTIONING_SERVICE); if (captioningManager != null && captioningManager.isEnabled()) { groupIndex = getGroupIndexForDefaultLocale(groups); } @@ -2451,7 +2338,7 @@ public class ReactExoplayerView extends FrameLayout implements // With only one tracks we can't remove any tracks so attempt to play it anyway tracks = allTracks; } else { - tracks = new ArrayList<>(supportedFormatLength + 1); + tracks = new ArrayList<>(supportedFormatLength + 1); for (int k = 0; k < allTracks.size(); k++) { Format format = group.getFormat(k); if (isFormatSupported(format)) { @@ -2477,8 +2364,7 @@ public class ReactExoplayerView extends FrameLayout implements .setRendererDisabled(rendererIndex, false); // Clear existing overrides for this track type to avoid conflicts - // But be careful with audio tracks - don't clear unless explicitly selecting a - // different track + // But be careful with audio tracks - don't clear unless explicitly selecting a different track if (trackType != C.TRACK_TYPE_AUDIO || !type.equals("default")) { selectionParameters.clearOverridesOfType(selectionOverride.getType()); } @@ -2494,7 +2380,7 @@ public class ReactExoplayerView extends FrameLayout implements selectionParameters.setForceHighestSupportedBitrate(false); selectionParameters.setForceLowestBitrate(false); DebugLog.d(TAG, "Audio track selection: group=" + groupIndex + ", tracks=" + tracks + - ", override=" + selectionOverride); + ", override=" + selectionOverride); } trackSelector.setParameters(selectionParameters.build()); @@ -2525,7 +2411,7 @@ public class ReactExoplayerView extends FrameLayout implements } private int getGroupIndexForDefaultLocale(TrackGroupArray groups) { - if (groups.length == 0) { + if (groups.length == 0){ return C.INDEX_UNSET; } @@ -2546,8 +2432,7 @@ public class ReactExoplayerView extends FrameLayout implements public void setSelectedVideoTrack(String type, String value) { videoTrackType = type; videoTrackValue = value; - if (!loadVideoStarted) - setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue); + if (!loadVideoStarted) setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue); } public void setSelectedAudioTrack(String type, String value) { @@ -2578,11 +2463,9 @@ public class ReactExoplayerView extends FrameLayout implements } public void setEnterPictureInPictureOnLeave(boolean enterPictureInPictureOnLeave) { - this.enterPictureInPictureOnLeave = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N - && enterPictureInPictureOnLeave; + this.enterPictureInPictureOnLeave = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && enterPictureInPictureOnLeave; if (player != null) { - PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, - this.enterPictureInPictureOnLeave); + PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, this.enterPictureInPictureOnLeave); } } @@ -2590,14 +2473,12 @@ public class ReactExoplayerView extends FrameLayout implements eventEmitter.onPictureInPictureStatusChanged.invoke(isInPictureInPicture); if (fullScreenPlayerView != null && fullScreenPlayerView.isShowing()) { - if (isInPictureInPicture) - fullScreenPlayerView.hideWithoutPlayer(); + if (isInPictureInPicture) fullScreenPlayerView.hideWithoutPlayer(); return; } Activity currentActivity = themedReactContext.getCurrentActivity(); - if (currentActivity == null) - return; + if (currentActivity == null) return; View decorView = currentActivity.getWindow().getDecorView(); ViewGroup rootView = decorView.findViewById(android.R.id.content); @@ -2607,7 +2488,7 @@ public class ReactExoplayerView extends FrameLayout implements LayoutParams.MATCH_PARENT); if (isInPictureInPicture) { - ViewGroup parent = (ViewGroup) exoPlayerView.getParent(); + ViewGroup parent = (ViewGroup)exoPlayerView.getParent(); if (parent != null) { parent.removeView(exoPlayerView); } @@ -2633,12 +2514,10 @@ public class ReactExoplayerView extends FrameLayout implements public void enterPictureInPictureMode() { PictureInPictureParams _pipParams = null; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - ArrayList actions = PictureInPictureUtil.getPictureInPictureActions(themedReactContext, - isPaused, pictureInPictureReceiver); + ArrayList actions = PictureInPictureUtil.getPictureInPictureActions(themedReactContext, isPaused, pictureInPictureReceiver); pictureInPictureParamsBuilder.setActions(actions); if (player.getPlaybackState() == Player.STATE_READY) { - pictureInPictureParamsBuilder - .setAspectRatio(PictureInPictureUtil.calcPictureInPictureAspectRatio(player)); + pictureInPictureParamsBuilder.setAspectRatio(PictureInPictureUtil.calcPictureInPictureAspectRatio(player)); } _pipParams = pictureInPictureParamsBuilder.build(); } @@ -2647,15 +2526,13 @@ public class ReactExoplayerView extends FrameLayout implements public void exitPictureInPictureMode() { Activity currentActivity = themedReactContext.getCurrentActivity(); - if (currentActivity == null) - return; + if (currentActivity == null) return; View decorView = currentActivity.getWindow().getDecorView(); ViewGroup rootView = decorView.findViewById(android.R.id.content); if (!rootViewChildrenOriginalVisibility.isEmpty()) { - if (exoPlayerView.getParent().equals(rootView)) - rootView.removeView(exoPlayerView); + if (exoPlayerView.getParent().equals(rootView)) rootView.removeView(exoPlayerView); for (int i = 0; i < rootView.getChildCount(); i++) { rootView.getChildAt(i).setVisibility(rootViewChildrenOriginalVisibility.get(i)); } @@ -2753,7 +2630,7 @@ public class ReactExoplayerView extends FrameLayout implements if (playbackServiceConnection == null && showNotificationControls) { setupPlaybackService(); - } else if (!showNotificationControls && playbackServiceConnection != null) { + } else if(!showNotificationControls && playbackServiceConnection != null) { cleanupPlaybackService(); } } @@ -2782,13 +2659,12 @@ public class ReactExoplayerView extends FrameLayout implements } if (isFullscreen) { - fullScreenPlayerView = new FullScreenPlayerView(getContext(), exoPlayerView, this, null, - new OnBackPressedCallback(true) { - @Override - public void handleOnBackPressed() { - setFullscreen(false); - } - }, controlsConfig); + fullScreenPlayerView = new FullScreenPlayerView(getContext(), exoPlayerView, this, null, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + setFullscreen(false); + } + }, controlsConfig); eventEmitter.onVideoFullscreenPlayerWillPresent.invoke(); if (fullScreenPlayerView != null) { fullScreenPlayerView.show(); @@ -2825,8 +2701,7 @@ public class ReactExoplayerView extends FrameLayout implements } @Override - public void onDrmSessionManagerError(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId, - @NonNull Exception e) { + public void onDrmSessionManagerError(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId, @NonNull Exception e) { DebugLog.d("DRM Info", "onDrmSessionManagerError"); eventEmitter.onVideoError.invoke("onDrmSessionManagerError", e, "3002"); } @@ -2844,7 +2719,7 @@ public class ReactExoplayerView extends FrameLayout implements /** * Handling controls prop * - * @param controls Controls prop, if true enable controls, if false disable them + * @param controls Controls prop, if true enable controls, if false disable them */ public void setControls(boolean controls) { this.controls = controls; @@ -2853,7 +2728,7 @@ public class ReactExoplayerView extends FrameLayout implements // Additional configuration for proper touch handling if (controls) { exoPlayerView.setControllerAutoShow(true); - exoPlayerView.setControllerHideOnTouch(true); // Show controls on touch, don't hide + exoPlayerView.setControllerHideOnTouch(true); // Show controls on touch, don't hide exoPlayerView.setControllerShowTimeoutMs(5000); } } @@ -2886,7 +2761,8 @@ public class ReactExoplayerView extends FrameLayout implements Map errMap = Map.of( "message", error.getMessage(), "code", String.valueOf(error.getErrorCode()), - "type", String.valueOf(error.getErrorType())); + "type", String.valueOf(error.getErrorType()) + ); eventEmitter.onReceiveAdEvent.invoke("ERROR", errMap); handleDaiBackupStream(); @@ -2920,10 +2796,10 @@ public class ReactExoplayerView extends FrameLayout implements * @return The configured IMA server-side ad insertion AdsLoader */ private ImaServerSideAdInsertionMediaSource.AdsLoader createAdsLoader() { - ImaServerSideAdInsertionMediaSource.AdsLoader.Builder adsLoaderBuilder = new ImaServerSideAdInsertionMediaSource.AdsLoader.Builder( - getContext(), exoPlayerView.getPlayerView()) - .setAdEventListener(this) - .setAdErrorListener(this); + ImaServerSideAdInsertionMediaSource.AdsLoader.Builder adsLoaderBuilder = + new ImaServerSideAdInsertionMediaSource.AdsLoader.Builder(getContext(), exoPlayerView.getPlayerView()) + .setAdEventListener(this) + .setAdErrorListener(this); return adsLoaderBuilder.build(); } @@ -2939,8 +2815,8 @@ public class ReactExoplayerView extends FrameLayout implements DataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(getContext()); DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(dataSourceFactory); - ImaServerSideAdInsertionMediaSource.Factory adsMediaSourceFactory = new ImaServerSideAdInsertionMediaSource.Factory( - daiAdsLoader, mediaSourceFactory); + ImaServerSideAdInsertionMediaSource.Factory adsMediaSourceFactory = + new ImaServerSideAdInsertionMediaSource.Factory(daiAdsLoader, mediaSourceFactory); mediaSourceFactory.setServerSideAdInsertionMediaSourceFactory(adsMediaSourceFactory); @@ -2974,8 +2850,7 @@ public class ReactExoplayerView extends FrameLayout implements /** * Requests a DAI stream from Google IMA using the ExoPlayer IMA extension. * - * Builds an SSAI URI based on the provided parameters and sets it on the - * player. + * Builds an SSAI URI based on the provided parameters and sets it on the player. * Supports both VOD (contentSourceId + videoId) and Live (assetKey) streams. * * @param runningSource The source containing DAI properties @@ -3008,8 +2883,7 @@ public class ReactExoplayerView extends FrameLayout implements .build() .buildUpon(); } else { - throw new IllegalArgumentException( - "Either assetKey (for live) or contentSourceId+videoId (for VOD) must be provided"); + throw new IllegalArgumentException("Either assetKey (for live) or contentSourceId+videoId (for VOD) must be provided"); } Map adTagParameters = adsProps.getAdTagParameters(); @@ -3032,8 +2906,7 @@ public class ReactExoplayerView extends FrameLayout implements /** * Handles fallback to backup stream when DAI stream fails. * - * If a backup stream URI is available in the DAI properties, it cleans up DAI - * resources + * If a backup stream URI is available in the DAI properties, it cleans up DAI resources * and switches to the backup stream. * * @return true if backup stream was successfully used, false otherwise diff --git a/src/components/calendar/CalendarSection.tsx b/src/components/calendar/CalendarSection.tsx index 671a963a..af3699c3 100644 --- a/src/components/calendar/CalendarSection.tsx +++ b/src/components/calendar/CalendarSection.tsx @@ -13,17 +13,12 @@ import { useTranslation } from 'react-i18next'; import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from 'date-fns'; import Animated, { FadeIn } from 'react-native-reanimated'; import { useTheme } from '../../contexts/ThemeContext'; +import { CalendarEpisode } from '../../types/calendar'; const { width } = Dimensions.get('window'); const COLUMN_COUNT = 7; // 7 days in a week const DAY_ITEM_SIZE = (width - 32 - 56) / 7; // Slightly smaller than 1/7 to fit all days -interface CalendarEpisode { - id: string; - releaseDate: string; - // Other properties can be included but aren't needed for the calendar -} - interface DayItemProps { date: Date; isCurrentMonth: boolean; @@ -45,8 +40,7 @@ const DayItem = ({ isSelected, hasEvents, onPress -}: DayItemProps) => { - const { currentTheme } = useTheme(); +}: DayItemProps) => { const { currentTheme } = useTheme(); return ( void; + anime: MalAnimeNode; + onUpdateSuccess: () => void; +} + +export const MalEditModal: React.FC = ({ + visible, + onClose, + anime, + onUpdateSuccess, +}) => { + const { currentTheme } = useTheme(); + const { showSuccess, showError } = useToast(); + + const [status, setStatus] = useState(anime.list_status.status); + const [episodes, setEpisodes] = useState(anime.list_status.num_episodes_watched.toString()); + const [score, setScore] = useState(anime.list_status.score.toString()); + const [isRewatching, setIsRewatching] = useState(anime.list_status.is_rewatching || false); + const [isUpdating, setIsUpdating] = useState(false); + const [isRemoving, setIsRemoving] = useState(false); + + useEffect(() => { + if (visible) { + setStatus(anime.list_status.status); + setEpisodes(anime.list_status.num_episodes_watched.toString()); + setScore(anime.list_status.score.toString()); + setIsRewatching(anime.list_status.is_rewatching || false); + } + }, [visible, anime]); + + const handleUpdate = async () => { + setIsUpdating(true); + try { + const epNum = parseInt(episodes, 10) || 0; + let scoreNum = parseInt(score, 10) || 0; + + // Validation: MAL scores must be between 0 and 10 + scoreNum = Math.max(0, Math.min(10, scoreNum)); + + await MalApiService.updateStatus(anime.node.id, status, epNum, scoreNum, isRewatching); + + showSuccess('Updated', `${anime.node.title} status updated on MAL`); + onUpdateSuccess(); + onClose(); + } catch (error) { + showError('Update Failed', 'Could not update MAL status'); + } finally { + setIsUpdating(false); + } + }; + + const handleRemove = async () => { + Alert.alert( + 'Remove from List', + `Are you sure you want to remove ${anime.node.title} from your MyAnimeList?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Remove', + style: 'destructive', + onPress: async () => { + setIsRemoving(true); + try { + await MalApiService.removeFromList(anime.node.id); + showSuccess('Removed', `${anime.node.title} removed from MAL`); + onUpdateSuccess(); + onClose(); + } catch (error) { + showError('Remove Failed', 'Could not remove from MAL'); + } finally { + setIsRemoving(false); + } + } + } + ] + ); + }; + + const statusOptions: { label: string; value: MalListStatus }[] = [ + { label: 'Watching', value: 'watching' }, + { label: 'Completed', value: 'completed' }, + { label: 'On Hold', value: 'on_hold' }, + { label: 'Dropped', value: 'dropped' }, + { label: 'Plan to Watch', value: 'plan_to_watch' }, + ]; + + return ( + + + + + + + Edit {anime.node.title} + + + + + + + + Status + + {statusOptions.map((option) => ( + setStatus(option.value)} + > + + {option.label} + + + ))} + + + + + + Episodes ({anime.node.num_episodes || '?'}) + + + + + + Score (0-10) + + + + + + + Rewatching + + Mark this if you are watching the series again. + + + + + + + {isUpdating ? ( + + ) : ( + Update MAL + )} + + + + {isRemoving ? ( + + ) : ( + + Remove from List + + )} + + + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.7)', + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + container: { + width: '100%', + maxWidth: 400, + }, + modalContent: { + borderRadius: 16, + padding: 20, + maxHeight: '90%', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, + }, + title: { + fontSize: 18, + fontWeight: '700', + flex: 1, + marginRight: 10, + }, + label: { + fontSize: 14, + fontWeight: '600', + marginBottom: 8, + marginTop: 12, + }, + statusGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + statusChip: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 20, + borderWidth: 1, + marginBottom: 4, + }, + statusText: { + fontSize: 13, + fontWeight: '500', + }, + inputRow: { + flexDirection: 'row', + gap: 16, + marginTop: 8, + }, + inputGroup: { + flex: 1, + }, + input: { + height: 44, + borderRadius: 8, + borderWidth: 1, + paddingHorizontal: 12, + fontSize: 16, + }, + rewatchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: 20, + paddingVertical: 8, + }, + rewatchTextContainer: { + flex: 1, + marginRight: 16, + }, + rewatchDescription: { + fontSize: 12, + marginTop: 2, + }, + updateButton: { + height: 48, + borderRadius: 24, + justifyContent: 'center', + alignItems: 'center', + marginTop: 24, + marginBottom: 10, + }, + updateButtonText: { + color: 'white', + fontSize: 16, + fontWeight: '700', + }, + removeButton: { + height: 48, + borderRadius: 24, + justifyContent: 'center', + alignItems: 'center', + marginTop: 8, + marginBottom: 20, + borderWidth: 1, + }, + removeButtonText: { + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 37fd01e2..976a7bb6 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -335,7 +335,7 @@ const ActionButtons = memo(({ return isWatched ? t('metadata.play') : playButtonText; }, [isWatched, playButtonText, type, watchProgress, groupedEpisodes]); - // Count additional buttons (excluding Play and Save) - AI Chat no longer counted + // Count additional buttons (AI Chat removed - now in top right corner) const hasTraktCollection = isAuthenticated; const hasRatings = type === 'series'; @@ -1882,25 +1882,25 @@ const HeroSection: React.FC = memo(({ {/* Optimized Action Buttons */} + toggleLibrary={handleToggleLibrary} + inLibrary={inLibrary} + type={type} + id={id} + navigation={navigation} + playButtonText={playButtonText} + animatedStyle={buttonsAnimatedStyle} + isWatched={isWatched} + watchProgress={watchProgress} + groupedEpisodes={groupedEpisodes} + metadata={metadata} + settings={settings} + // Trakt integration props + isAuthenticated={isAuthenticated} + isInWatchlist={isInWatchlist} + isInCollection={isInCollection} + onToggleWatchlist={onToggleWatchlist} + onToggleCollection={onToggleCollection} + /> diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index 4d2035f8..c0c23833 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -17,8 +17,9 @@ import { TraktService } from '../../services/traktService'; import { watchedService } from '../../services/watchedService'; import { logger } from '../../utils/logger'; import { mmkvStorage } from '../../services/mmkvStorage'; +import { MalSync } from '../../services/mal/MalSync'; -// Enhanced responsive breakpoints for Seasons Section +// ... other imports const BREAKPOINTS = { phone: 0, tablet: 768, @@ -33,7 +34,15 @@ interface SeriesContentProps { onSeasonChange: (season: number) => void; onSelectEpisode: (episode: Episode) => void; groupedEpisodes?: { [seasonNumber: number]: Episode[] }; - metadata?: { poster?: string; id?: string }; + metadata?: { + poster?: string; + id?: string; + name?: string; + mal_id?: number; + external_ids?: { + mal_id?: number; + } + }; imdbId?: string; // IMDb ID for Trakt sync } @@ -573,12 +582,31 @@ const SeriesContentComponent: React.FC = ({ // 3. Background Async Operation const showImdbId = imdbId || metadata.id; + const malId = (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id; + const tmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id; + + // Calculate dayIndex for same-day releases + let dayIndex = 0; + if (episode.air_date) { + const sameDayEpisodes = episodes + .filter(ep => ep.air_date === episode.air_date) + .sort((a, b) => a.episode_number - b.episode_number); + dayIndex = sameDayEpisodes.findIndex(ep => ep.episode_number === episode.episode_number); + if (dayIndex < 0) dayIndex = 0; + } + try { const result = await watchedService.markEpisodeAsWatched( - showImdbId, - metadata.id, + showImdbId || 'Anime', + metadata.id || '', episode.season_number, - episode.episode_number + episode.episode_number, + new Date(), + episode.air_date, + metadata?.name, + malId, + dayIndex, + tmdbId ); // Reload to ensure consistency (e.g. if optimistic update was slightly off or for other effects) @@ -664,6 +692,24 @@ const SeriesContentComponent: React.FC = ({ episodeNumbers ); + // Sync to MAL (last episode of the season) + const malEnabled = mmkvStorage.getBoolean('mal_enabled') ?? true; + if (malEnabled && metadata?.name && episodeNumbers.length > 0) { + const lastEp = Math.max(...episodeNumbers); + const lastEpisodeData = seasonEpisodes.find(e => e.episode_number === lastEp); + const totalEpisodes = Object.values(groupedEpisodes).reduce((acc, curr) => acc + (curr?.length || 0), 0); + + MalSync.scrobbleEpisode( + metadata.name, + lastEp, + totalEpisodes, + 'series', + currentSeason, + imdbId, + lastEpisodeData?.air_date // Pass release date for accuracy + ); + } + // Re-sync with source of truth loadEpisodesProgress(); @@ -2349,4 +2395,4 @@ const styles = StyleSheet.create({ fontWeight: '800', }, -}); \ No newline at end of file +}); diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index ed89b1f9..6b06853c 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -55,9 +55,11 @@ import { MpvPlayerRef } from './android/MpvPlayer'; // Utils import { logger } from '../../utils/logger'; import { styles } from './utils/playerStyles'; -import { formatTime, isHlsStream, getHlsHeaders, defaultAndroidHeaders, parseSRT } from './utils/playerUtils'; +import { formatTime, isHlsStream, getHlsHeaders, defaultAndroidHeaders, parseSubtitle } from './utils/playerUtils'; import { storageService } from '../../services/storageService'; import stremioService from '../../services/stremioService'; +import { localScraperService } from '../../services/pluginService'; +import { TMDBService } from '../../services/tmdbService'; import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes'; import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils'; import { buildExoAudioTrackName, buildExoSubtitleTrackName } from './android/components/VideoSurface'; @@ -75,7 +77,7 @@ const AndroidVideoPlayer: React.FC = () => { const { uri, title = 'Episode Name', season, episode, episodeTitle, quality, year, streamProvider, streamName, headers, id, type, episodeId, imdbId, - availableStreams: passedAvailableStreams, backdrop, groupedEpisodes + availableStreams: passedAvailableStreams, backdrop, groupedEpisodes, releaseDate } = route.params; // --- State & Custom Hooks --- @@ -219,6 +221,21 @@ const AndroidVideoPlayer: React.FC = () => { episodeId: episodeId }); + const currentMalId = (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id; + const currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id; + + // Calculate dayIndex for same-day releases + const currentDayIndex = useMemo(() => { + if (!releaseDate || !groupedEpisodes) return 0; + // Flatten groupedEpisodes to search for same-day releases + const allEpisodes = Object.values(groupedEpisodes).flat(); + const sameDayEpisodes = allEpisodes + .filter(ep => ep.air_date === releaseDate) + .sort((a, b) => a.episode_number - b.episode_number); + const idx = sameDayEpisodes.findIndex(ep => ep.episode_number === episode); + return idx >= 0 ? idx : 0; + }, [releaseDate, groupedEpisodes, episode]); + const watchProgress = useWatchProgress( id, type, episodeId, playerState.currentTime, @@ -227,7 +244,15 @@ const AndroidVideoPlayer: React.FC = () => { traktAutosync, controlsHook.seekToTime, currentStreamProvider, - isInPictureInPicture || isPiPTransitionPending + imdbId, + season, + episode, + releaseDate, + currentMalId, + currentDayIndex, + currentTmdbId, + isInPictureInPicture || isPiPTransitionPending, + metadata?.name ); const gestureControls = usePlayerGestureControls({ @@ -717,41 +742,85 @@ const AndroidVideoPlayer: React.FC = () => { // Subtitle addon fetching const fetchAvailableSubtitles = useCallback(async () => { const targetImdbId = imdbId; - if (!targetImdbId) { - logger.warn('[AndroidVideoPlayer] No IMDB ID for subtitle fetch'); - return; - } - + setIsLoadingSubtitleList(true); try { const stremioType = type === 'series' ? 'series' : 'movie'; const stremioVideoId = stremioType === 'series' && season && episode ? `series:${targetImdbId}:${season}:${episode}` : undefined; - const results = await stremioService.getSubtitles(stremioType, targetImdbId, stremioVideoId); - const subs: WyzieSubtitle[] = (results || []).map((sub: any) => ({ - id: sub.id || `${sub.lang}-${sub.url}`, - url: sub.url, - flagUrl: '', - format: 'srt', - encoding: 'utf-8', - media: sub.addonName || sub.addon || '', - display: sub.lang || 'Unknown', - language: (sub.lang || '').toLowerCase(), - isHearingImpaired: false, - source: sub.addonName || sub.addon || 'Addon', - })); + // 1. Fetch from Stremio addons + const stremioPromise = stremioService.getSubtitles(stremioType, targetImdbId || '', stremioVideoId) + .then(results => (results || []).map((sub: any) => ({ + id: sub.id || `${sub.lang}-${sub.url}`, + url: sub.url, + flagUrl: '', + format: 'srt', + encoding: 'utf-8', + media: sub.addonName || sub.addon || '', + display: sub.lang || 'Unknown', + language: (sub.lang || '').toLowerCase(), + isHearingImpaired: false, + source: sub.addonName || sub.addon || 'Addon', + }))) + .catch(e => { + logger.error('[AndroidVideoPlayer] Error fetching Stremio subtitles', e); + return []; + }); - setAvailableSubtitles(subs); - logger.info(`[AndroidVideoPlayer] Fetched ${subs.length} addon subtitles`); - // Auto-selection is now handled by useEffect that waits for internal tracks + // 2. Fetch from Local Plugins + const pluginPromise = (async () => { + try { + let tmdbIdStr: string | null = null; + + // Try to resolve TMDB ID + if (id && id.startsWith('tmdb:')) { + tmdbIdStr = id.split(':')[1]; + } else if (targetImdbId) { + const resolvedId = await TMDBService.getInstance().findTMDBIdByIMDB(targetImdbId); + if (resolvedId) tmdbIdStr = resolvedId.toString(); + } + + if (tmdbIdStr) { + const results = await localScraperService.getSubtitles( + stremioType === 'series' ? 'tv' : 'movie', + tmdbIdStr, + season, + episode + ); + + return results.map((sub: any) => ({ + id: sub.url, // Use URL as ID for simple deduplication + url: sub.url, + flagUrl: '', + format: sub.format || 'srt', + encoding: 'utf-8', + media: sub.label || sub.addonName || 'Plugin', + display: sub.label || sub.lang || 'Plugin', + language: (sub.lang || 'en').toLowerCase(), + isHearingImpaired: false, + source: sub.addonName || 'Plugin' + })); + } + } catch (e) { + logger.warn('[AndroidVideoPlayer] Error fetching plugin subtitles', e); + } + return []; + })(); + + const [stremioSubs, pluginSubs] = await Promise.all([stremioPromise, pluginPromise]); + const allSubs = [...pluginSubs, ...stremioSubs]; + + setAvailableSubtitles(allSubs); + logger.info(`[AndroidVideoPlayer] Fetched ${allSubs.length} subtitles (${stremioSubs.length} Stremio, ${pluginSubs.length} Plugins)`); + } catch (e) { - logger.error('[AndroidVideoPlayer] Error fetching addon subtitles', e); + logger.error('[AndroidVideoPlayer] Error in fetchAvailableSubtitles', e); } finally { setIsLoadingSubtitleList(false); } - }, [imdbId, type, season, episode]); + }, [imdbId, type, season, episode, id]); const loadWyzieSubtitle = useCallback(async (subtitle: WyzieSubtitle) => { if (!subtitle.url) return; @@ -770,7 +839,7 @@ const AndroidVideoPlayer: React.FC = () => { } // Parse subtitle file - const parsedCues = parseSRT(srtContent); + const parsedCues = parseSubtitle(srtContent, subtitle.url); setCustomSubtitles(parsedCues); setUseCustomSubtitles(true); setSelectedExternalSubtitleId(subtitle.id); // Track the selected external subtitle @@ -1091,6 +1160,7 @@ const AndroidVideoPlayer: React.FC = () => { episode={episode} malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id} kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined} + releaseDate={releaseDate} skipIntervals={skipIntervals} currentTime={playerState.currentTime} onSkip={(endTime) => controlsHook.seekToTime(endTime)} diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index c5d36e0e..4fc0a9e2 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; +import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { View, StatusBar, StyleSheet, Animated, Dimensions, ActivityIndicator } from 'react-native'; import { useNavigation, useRoute } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -53,8 +53,10 @@ import { logger } from '../../utils/logger'; // Utils import { formatTime } from './utils/playerUtils'; +import { localScraperService } from '../../services/pluginService'; +import { TMDBService } from '../../services/tmdbService'; import { WyzieSubtitle } from './utils/playerTypes'; -import { parseSRT } from './utils/subtitleParser'; +import { parseSubtitle } from './utils/subtitleParser'; import { findBestSubtitleTrack, autoSelectAudioTrack, findBestAudioTrack } from './utils/trackSelectionUtils'; import { useSettings } from '../../hooks/useSettings'; import { useTheme } from '../../contexts/ThemeContext'; @@ -78,6 +80,7 @@ interface PlayerRouteParams { backdrop?: string; availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; headers?: Record; + releaseDate?: string; initialPosition?: number; } @@ -92,7 +95,7 @@ const KSPlayerCore: React.FC = () => { const { uri, title, episodeTitle, season, episode, id, type, quality, year, episodeId, imdbId, backdrop, availableStreams, - headers, streamProvider, streamName, + headers, streamProvider, streamName, releaseDate, initialPosition: routeInitialPosition } = params; @@ -239,13 +242,38 @@ const KSPlayerCore: React.FC = () => { } }); + const currentMalId = (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id; + const currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id; + + // Calculate dayIndex for same-day releases + const currentDayIndex = useMemo(() => { + if (!releaseDate || !groupedEpisodes) return 0; + // Flatten groupedEpisodes to search for same-day releases + const allEpisodes = Object.values(groupedEpisodes).flat() as any[]; + const sameDayEpisodes = allEpisodes + .filter(ep => ep.air_date === releaseDate) + .sort((a, b) => a.episode_number - b.episode_number); + const idx = sameDayEpisodes.findIndex(ep => ep.episode_number === episode); + return idx >= 0 ? idx : 0; + }, [releaseDate, groupedEpisodes, episode]); + const watchProgress = useWatchProgress( id, type, episodeId, currentTime, duration, paused, traktAutosync, - controls.seekToTime + controls.seekToTime, + undefined, + imdbId, + season, + episode, + releaseDate, + currentMalId, + currentDayIndex, + currentTmdbId, + false, // KSPlayer doesn't support PiP yet + metadata?.name ); // Gestures @@ -334,32 +362,80 @@ const KSPlayerCore: React.FC = () => { // Subtitle Fetching Logic const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = true) => { const targetImdbId = imdbIdParam || imdbId; - if (!targetImdbId) return; - + customSubs.setIsLoadingSubtitleList(true); try { const stremioType = type === 'series' ? 'series' : 'movie'; - const stremioVideoId = stremioType === 'series' && season && episode ? `series:${targetImdbId}:${season}:${episode}` : undefined; - const results = await stremioService.getSubtitles(stremioType, targetImdbId, stremioVideoId); + const stremioVideoId = stremioType === 'series' && season && episode + ? `series:${targetImdbId}:${season}:${episode}` + : undefined; - const subs: WyzieSubtitle[] = (results || []).map((sub: any) => ({ - id: sub.id || `${sub.lang}-${sub.url}`, - url: sub.url, - flagUrl: '', - format: 'srt', - encoding: 'utf-8', - media: sub.addonName || sub.addon || '', - display: sub.lang || 'Unknown', - language: (sub.lang || '').toLowerCase(), - isHearingImpaired: false, - source: sub.addonName || sub.addon || 'Addon', - })); + // 1. Fetch from Stremio addons + const stremioPromise = stremioService.getSubtitles(stremioType, targetImdbId || '', stremioVideoId) + .then(results => (results || []).map((sub: any) => ({ + id: sub.id || `${sub.lang}-${sub.url}`, + url: sub.url, + flagUrl: '', + format: 'srt', + encoding: 'utf-8', + media: sub.addonName || sub.addon || '', + display: sub.lang || 'Unknown', + language: (sub.lang || '').toLowerCase(), + isHearingImpaired: false, + source: sub.addonName || sub.addon || 'Addon', + }))) + .catch(e => { + logger.error('[KSPlayerCore] Error fetching Stremio subtitles', e); + return []; + }); - customSubs.setAvailableSubtitles(subs); - // Auto-selection is now handled by useEffect that waits for internal tracks - // This ensures internal tracks are considered before falling back to external - } catch (e) { - logger.error('[VideoPlayer] Error fetching subtitles', e); + // 2. Fetch from Local Plugins + const pluginPromise = (async () => { + try { + let tmdbIdStr: string | null = null; + + if (id && id.startsWith('tmdb:')) { + tmdbIdStr = id.split(':')[1]; + } else if (targetImdbId) { + const resolvedId = await TMDBService.getInstance().findTMDBIdByIMDB(targetImdbId); + if (resolvedId) tmdbIdStr = resolvedId.toString(); + } + + if (tmdbIdStr) { + const results = await localScraperService.getSubtitles( + stremioType === 'series' ? 'tv' : 'movie', + tmdbIdStr, + season, + episode + ); + + return results.map((sub: any) => ({ + id: sub.url, + url: sub.url, + flagUrl: '', + format: sub.format || 'srt', + encoding: 'utf-8', + media: sub.label || sub.addonName || 'Plugin', + display: sub.label || sub.lang || 'Plugin', + language: (sub.lang || 'en').toLowerCase(), + isHearingImpaired: false, + source: sub.addonName || 'Plugin' + })); + } + } catch (e) { + logger.warn('[KSPlayerCore] Error fetching plugin subtitles', e); + } + return []; + })(); + + const [stremioSubs, pluginSubs] = await Promise.all([stremioPromise, pluginPromise]); + const allSubs = [...pluginSubs, ...stremioSubs]; + + customSubs.setAvailableSubtitles(allSubs); + logger.info(`[KSPlayerCore] Fetched ${allSubs.length} subtitles (${stremioSubs.length} Stremio, ${pluginSubs.length} Plugins)`); + + } catch (error) { + logger.error('[KSPlayerCore] Error in fetchAvailableSubtitles', error); } finally { customSubs.setIsLoadingSubtitleList(false); } @@ -377,7 +453,8 @@ const KSPlayerCore: React.FC = () => { const resp = await fetch(subtitle.url); srtContent = await resp.text(); } - const parsedCues = parseSRT(srtContent); + // Parse subtitle file + const parsedCues = parseSubtitle(srtContent, subtitle.url); customSubs.setCustomSubtitles(parsedCues); customSubs.setUseCustomSubtitles(true); customSubs.setSelectedExternalSubtitleId(subtitle.id); // Track the selected external subtitle @@ -965,6 +1042,7 @@ const KSPlayerCore: React.FC = () => { episode={episode} malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id} kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined} + releaseDate={releaseDate} skipIntervals={skipIntervals} currentTime={currentTime} onSkip={(endTime) => controls.seekToTime(endTime)} diff --git a/src/components/player/android/hooks/usePlayerSetup.ts b/src/components/player/android/hooks/usePlayerSetup.ts index 60e8d94f..9e81e336 100644 --- a/src/components/player/android/hooks/usePlayerSetup.ts +++ b/src/components/player/android/hooks/usePlayerSetup.ts @@ -1,6 +1,5 @@ import { useEffect, useRef } from 'react'; import { StatusBar, Platform, Dimensions, AppState } from 'react-native'; -import RNImmersiveMode from 'react-native-immersive-mode'; import * as NavigationBar from 'expo-navigation-bar'; import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'; @@ -8,6 +7,16 @@ import { logger } from '../../../../utils/logger'; import { useFocusEffect } from '@react-navigation/native'; import { useCallback } from 'react'; +// Optional Android immersive mode module +let RNImmersiveMode: any = null; +if (Platform.OS === 'android') { + try { + RNImmersiveMode = require('react-native-immersive-mode').default; + } catch { + RNImmersiveMode = null; + } +} + const DEBUG_MODE = false; export const usePlayerSetup = ( @@ -35,8 +44,14 @@ export const usePlayerSetup = ( const enableImmersiveMode = async () => { if (Platform.OS === 'android') { // Standard immersive mode - RNImmersiveMode.setBarTranslucent(true); - RNImmersiveMode.fullLayout(true); + if (RNImmersiveMode) { + try { + RNImmersiveMode.setBarTranslucent(true); + RNImmersiveMode.fullLayout(true); + } catch (e) { + console.warn('[usePlayerSetup] RNImmersiveMode failed:', e); + } + } StatusBar.setHidden(true, 'none'); // Explicitly hide bottom navigation bar using Expo @@ -51,8 +66,12 @@ export const usePlayerSetup = ( const disableImmersiveMode = async () => { if (Platform.OS === 'android') { - RNImmersiveMode.setBarTranslucent(false); - RNImmersiveMode.fullLayout(false); + if (RNImmersiveMode) { + try { + RNImmersiveMode.setBarTranslucent(false); + RNImmersiveMode.fullLayout(false); + } catch (e) { } + } StatusBar.setHidden(false, 'fade'); try { diff --git a/src/components/player/hooks/useSkipSegments.ts b/src/components/player/hooks/useSkipSegments.ts index 3a81e86d..a5e868fe 100644 --- a/src/components/player/hooks/useSkipSegments.ts +++ b/src/components/player/hooks/useSkipSegments.ts @@ -9,6 +9,7 @@ interface UseSkipSegmentsProps { episode?: number; malId?: string; kitsuId?: string; + releaseDate?: string; enabled: boolean; } @@ -19,6 +20,7 @@ export const useSkipSegments = ({ episode, malId, kitsuId, + releaseDate, enabled }: UseSkipSegmentsProps) => { const [segments, setSegments] = useState([]); @@ -27,7 +29,7 @@ export const useSkipSegments = ({ const lastKeyRef = useRef(''); useEffect(() => { - const key = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}`; + const key = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}-${releaseDate}`; if (!enabled || type !== 'series' || (!imdbId && !malId && !kitsuId) || !season || !episode) { setSegments([]); @@ -53,7 +55,7 @@ export const useSkipSegments = ({ const fetchSegments = async () => { try { - const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId); + const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId, releaseDate); // Ignore stale responses from old requests. if (cancelled || lastKeyRef.current !== key) return; @@ -76,7 +78,7 @@ export const useSkipSegments = ({ return () => { cancelled = true; }; - }, [imdbId, type, season, episode, malId, kitsuId, enabled]); + }, [imdbId, type, season, episode, malId, kitsuId, releaseDate, enabled]); const getActiveSegment = (currentTime: number) => { return segments.find( diff --git a/src/components/player/hooks/useWatchProgress.ts b/src/components/player/hooks/useWatchProgress.ts index bafafa64..99a7b63b 100644 --- a/src/components/player/hooks/useWatchProgress.ts +++ b/src/components/player/hooks/useWatchProgress.ts @@ -3,6 +3,7 @@ import { AppState } from 'react-native'; import { storageService } from '../../../services/storageService'; import { logger } from '../../../utils/logger'; import { useSettings } from '../../../hooks/useSettings'; +import { watchedService } from '../../../services/watchedService'; export const useWatchProgress = ( id: string | undefined, @@ -14,7 +15,16 @@ export const useWatchProgress = ( traktAutosync: any, seekToTime: (time: number) => void, addonId?: string, - isInPictureInPicture: boolean = false + // New parameters for MAL scrobbling + imdbId?: string, + season?: number, + episode?: number, + releaseDate?: string, + malId?: number, + dayIndex?: number, + tmdbId?: number, + isInPictureInPicture: boolean = false, + title?: string ) => { const [resumePosition, setResumePosition] = useState(null); const [savedDuration, setSavedDuration] = useState(null); @@ -22,12 +32,40 @@ export const useWatchProgress = ( const [showResumeOverlay, setShowResumeOverlay] = useState(false); const { settings: appSettings } = useSettings(); const initialSeekTargetRef = useRef(null); + const hasScrobbledRef = useRef(false); const wasPausedRef = useRef(paused); + const [progressSaveInterval, setProgressSaveInterval] = useState(null); - // Values refs for unmount cleanup + // Values refs for unmount cleanup and stale closure prevention const currentTimeRef = useRef(currentTime); const durationRef = useRef(duration); + const imdbIdRef = useRef(imdbId); + const seasonRef = useRef(season); + const episodeRef = useRef(episode); + const releaseDateRef = useRef(releaseDate); + const malIdRef = useRef(malId); + const dayIndexRef = useRef(dayIndex); + const tmdbIdRef = useRef(tmdbId); const isInPictureInPictureRef = useRef(isInPictureInPicture); + const titleRef = useRef(title); + + // Sync refs + useEffect(() => { + imdbIdRef.current = imdbId; + seasonRef.current = season; + episodeRef.current = episode; + releaseDateRef.current = releaseDate; + malIdRef.current = malId; + dayIndexRef.current = dayIndex; + tmdbIdRef.current = tmdbId; + isInPictureInPictureRef.current = isInPictureInPicture; + titleRef.current = title; + }, [imdbId, season, episode, releaseDate, malId, dayIndex, tmdbId, isInPictureInPicture, title]); + + // Reset scrobble flag when content changes + useEffect(() => { + hasScrobbledRef.current = false; + }, [id, episodeId]); useEffect(() => { currentTimeRef.current = currentTime; @@ -37,10 +75,6 @@ export const useWatchProgress = ( durationRef.current = duration; }, [duration]); - useEffect(() => { - isInPictureInPictureRef.current = isInPictureInPicture; - }, [isInPictureInPicture]); - // Keep latest traktAutosync ref to avoid dependency cycles in listeners const traktAutosyncRef = useRef(traktAutosync); useEffect(() => { @@ -129,6 +163,39 @@ export const useWatchProgress = ( try { await storageService.setWatchProgress(id, type, progress, episodeId); await traktAutosync.handleProgressUpdate(currentTimeRef.current, durationRef.current); + + // Requirement 1: Auto Episode Tracking (>= 90% completion) + const progressPercent = (currentTimeRef.current / durationRef.current) * 100; + if (progressPercent >= 90 && !hasScrobbledRef.current) { + hasScrobbledRef.current = true; + logger.log(`[useWatchProgress] 90% threshold reached, scrobbling to MAL...`); + + const currentImdbId = imdbIdRef.current; + const currentSeason = seasonRef.current; + const currentEpisode = episodeRef.current; + const currentReleaseDate = releaseDateRef.current; + const currentMalId = malIdRef.current; + const currentDayIndex = dayIndexRef.current; + const currentTmdbId = tmdbIdRef.current; + const currentTitle = titleRef.current; + + if (type === 'series' && currentImdbId && currentSeason !== undefined && currentEpisode !== undefined) { + watchedService.markEpisodeAsWatched( + currentImdbId, + id, + currentSeason, + currentEpisode, + new Date(), + currentReleaseDate, + undefined, + currentMalId, + currentDayIndex, + currentTmdbId + ); + } else if (type === 'movie' && currentImdbId) { + watchedService.markMovieAsWatched(currentImdbId, new Date(), currentMalId, currentTmdbId, currentTitle); + } + } } catch (error) { logger.error('[useWatchProgress] Error saving watch progress:', error); } @@ -137,6 +204,7 @@ export const useWatchProgress = ( useEffect(() => { + // Handle pause transitions (upstream) if (wasPausedRef.current !== paused) { const becamePaused = paused; wasPausedRef.current = paused; @@ -144,7 +212,23 @@ export const useWatchProgress = ( void saveWatchProgress(); } } - }, [paused]); + + // Handle periodic save when playing (MAL branch) + if (id && type && !paused) { + if (progressSaveInterval) clearInterval(progressSaveInterval); + + // Use refs inside the interval so we don't need to restart it on every second + const interval = setInterval(() => { + saveWatchProgress(); + }, 10000); + + setProgressSaveInterval(interval); + return () => { + clearInterval(interval); + setProgressSaveInterval(null); + }; + } + }, [id, type, paused]); // Unmount Save - deferred to allow navigation to complete first useEffect(() => { diff --git a/src/components/player/overlays/SkipIntroButton.tsx b/src/components/player/overlays/SkipIntroButton.tsx index 67f72173..9aa8021f 100644 --- a/src/components/player/overlays/SkipIntroButton.tsx +++ b/src/components/player/overlays/SkipIntroButton.tsx @@ -23,6 +23,7 @@ interface SkipIntroButtonProps { episode?: number; malId?: string; kitsuId?: string; + releaseDate?: string; skipIntervals?: SkipInterval[] | null; currentTime: number; onSkip: (endTime: number) => void; @@ -37,6 +38,7 @@ export const SkipIntroButton: React.FC = ({ episode, malId, kitsuId, + releaseDate, skipIntervals: externalSkipIntervals, currentTime, onSkip, @@ -56,6 +58,7 @@ export const SkipIntroButton: React.FC = ({ episode, malId, kitsuId, + releaseDate, // Allow parent components to provide pre-fetched intervals to avoid duplicate requests. enabled: skipIntroEnabled && !externalSkipIntervals }); @@ -79,7 +82,7 @@ export const SkipIntroButton: React.FC = ({ useEffect(() => { setHasSkippedCurrent(false); setAutoHidden(false); - }, [imdbId, season, episode, malId, kitsuId]); + }, [imdbId, season, episode, malId, kitsuId, releaseDate]); // Determine active interval based on current playback position useEffect(() => { diff --git a/src/components/player/utils/playerUtils.ts b/src/components/player/utils/playerUtils.ts index e2124358..8810629a 100644 --- a/src/components/player/utils/playerUtils.ts +++ b/src/components/player/utils/playerUtils.ts @@ -177,6 +177,9 @@ export const parseSRT = (srtContent: string): SubtitleCue[] => { return parseSRTEnhanced(srtContent); }; +// Export universal subtitle parser +export { parseSubtitle }; + /** * Detect if text contains primarily RTL (right-to-left) characters * Checks for Arabic, Hebrew, Persian, Urdu, and other RTL scripts diff --git a/src/hooks/useCalendarData.ts b/src/hooks/useCalendarData.ts index e2884cd3..2994b036 100644 --- a/src/hooks/useCalendarData.ts +++ b/src/hooks/useCalendarData.ts @@ -8,27 +8,7 @@ import { logger } from '../utils/logger'; import { memoryManager } from '../utils/memoryManager'; import { parseISO, isBefore, isAfter, startOfToday, addWeeks, isThisWeek } from 'date-fns'; import { StreamingContent } from '../services/catalogService'; - -interface CalendarEpisode { - id: string; - seriesId: string; - title: string; - seriesName: string; - poster: string; - releaseDate: string; - season: number; - episode: number; - overview: string; - vote_average: number; - still_path: string | null; - season_poster_path: string | null; - addonId?: string; -} - -interface CalendarSection { - title: string; - data: CalendarEpisode[]; -} +import { CalendarEpisode, CalendarSection } from '../types/calendar'; interface UseCalendarDataReturn { calendarData: CalendarSection[]; @@ -334,6 +314,8 @@ export const useCalendarData = (): UseCalendarDataReturn => { // Sort episodes by release date with error handling allEpisodes.sort((a, b) => { try { + if (!a.releaseDate) return 1; + if (!b.releaseDate) return -1; const dateA = new Date(a.releaseDate).getTime(); const dateB = new Date(b.releaseDate).getTime(); return dateA - dateB; diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 5c7e13b1..fc41f662 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -13,6 +13,7 @@ import { mmkvStorage } from '../services/mmkvStorage'; import { Stream } from '../types/metadata'; import { storageService } from '../services/storageService'; import { useSettings } from './useSettings'; +import { MalSync } from '../services/mal/MalSync'; // Constants for timeouts and retries const API_TIMEOUT = 10000; // 10 seconds @@ -488,6 +489,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const loadMetadata = async () => { try { + console.log('🚀 [useMetadata] loadMetadata CALLED for:', { id, type }); console.log('🔍 [useMetadata] loadMetadata started:', { id, type, @@ -541,6 +543,18 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Handle TMDB-specific IDs let actualId = id; + + // Handle MAL IDs + if (id.startsWith('mal:')) { + // STRICT MODE: Do NOT convert to IMDb/Cinemeta. + // We want to force the app to use AnimeKitsu (or other MAL-compatible addons) for metadata. + // This ensures we get correct Season/Episode mapping (Separate entries) instead of Cinemeta's "S1E26" mess. + console.log('🔍 [useMetadata] Keeping MAL ID for metadata fetch:', id); + + // Note: Stream fetching (stremioService) WILL still convert this to IMDb secretly + // to ensure Torrentio works, but the Metadata UI will stay purely MAL-based. + } + if (id.startsWith('tmdb:')) { // Always try the original TMDB ID first - let addons decide if they support it console.log('🔍 [useMetadata] TMDB ID detected, trying original ID first:', { originalId: id }); @@ -731,7 +745,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat console.log('🔍 [useMetadata] Starting parallel data fetch:', { type, actualId, addonId, apiTimeout: API_TIMEOUT }); if (__DEV__) logger.log('[loadMetadata] fetching addon metadata', { type, actualId, addonId }); - let contentResult = null; + let contentResult: any = null; let lastError = null; // Check if user prefers external meta addons @@ -814,11 +828,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const [content, castData] = await Promise.allSettled([ // Load content with timeout and retry withRetry(async () => { + console.log('⚡ [useMetadata] Calling catalogService.getEnhancedContentDetails...'); console.log('🔍 [useMetadata] Calling catalogService.getEnhancedContentDetails:', { type, actualId, addonId }); const result = await withTimeout( catalogService.getEnhancedContentDetails(type, actualId, addonId), API_TIMEOUT ); + console.log('✅ [useMetadata] catalogService returned:', result ? 'DATA' : 'NULL'); // Store the actual ID used (could be IMDB) if (actualId.startsWith('tt')) { setImdbId(actualId); @@ -2030,8 +2046,9 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Start Stremio request using the converted episode ID format if (__DEV__) console.log('🎬 [loadEpisodeStreams] Using episode ID for Stremio addons:', stremioEpisodeId); - const requestedContentType = isCollection ? 'movie' : type; - const contentType = requestedContentType; + // For collections, treat episodes as individual movies, not series + // For other types (e.g. StreamsPPV), preserve the original type unless it's explicitly 'series' logic we want + const contentType = isCollection ? 'movie' : type; if (__DEV__) console.log(`🎬 [loadEpisodeStreams] Using content type: ${contentType} for ${isCollection ? 'collection' : type}`); processStremioSource(contentType, stremioEpisodeId, true); diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index a818fcf2..39597426 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -57,6 +57,8 @@ import TMDBSettingsScreen from '../screens/TMDBSettingsScreen'; import HomeScreenSettings from '../screens/HomeScreenSettings'; import HeroCatalogsScreen from '../screens/HeroCatalogsScreen'; import TraktSettingsScreen from '../screens/TraktSettingsScreen'; +import MalSettingsScreen from '../screens/MalSettingsScreen'; +import MalLibraryScreen from '../screens/MalLibraryScreen'; import SimklSettingsScreen from '../screens/SimklSettingsScreen'; import PlayerSettingsScreen from '../screens/PlayerSettingsScreen'; import ThemeScreen from '../screens/ThemeScreen'; @@ -152,6 +154,7 @@ export type RootStackParamList = { availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; backdrop?: string; videoType?: string; + releaseDate?: string; groupedEpisodes?: { [seasonNumber: number]: any[] }; }; PlayerAndroid: { @@ -172,6 +175,7 @@ export type RootStackParamList = { availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; backdrop?: string; videoType?: string; + releaseDate?: string; groupedEpisodes?: { [seasonNumber: number]: any[] }; }; Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string }; @@ -190,6 +194,8 @@ export type RootStackParamList = { HomeScreenSettings: undefined; HeroCatalogs: undefined; TraktSettings: undefined; + MalSettings: undefined; + MalLibrary: undefined; SimklSettings: undefined; PlayerSettings: undefined; ThemeSettings: undefined; @@ -1573,6 +1579,36 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta }, }} /> + + { const { t } = useTranslation(); const navigation = useNavigation>(); @@ -75,14 +58,90 @@ const CalendarScreen = () => { const [uiReady, setUiReady] = useState(false); const [selectedDate, setSelectedDate] = useState(null); const [filteredEpisodes, setFilteredEpisodes] = useState([]); + + // AniList Integration + const [calendarSource, setCalendarSource] = useState<'nuvio' | 'anilist'>('nuvio'); + const [aniListSchedule, setAniListSchedule] = useState([]); + const [aniListLoading, setAniListLoading] = useState(false); + + const fetchAniListSchedule = useCallback(async () => { + setAniListLoading(true); + try { + const schedule = await AniListService.getWeeklySchedule(); + + // Group by Day + const grouped: Record = {}; + const daysOrder = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + + schedule.forEach((item) => { + const date = new Date(item.airingAt * 1000); + const dayName = format(date, 'EEEE'); // Monday, Tuesday... + + if (!grouped[dayName]) { + grouped[dayName] = []; + } + + const episode: CalendarEpisode = { + id: `kitsu:${item.media.idMal}`, // Fallback ID for now, ideally convert to IMDb/TMDB if possible + seriesId: `mal:${item.media.idMal}`, // Use MAL ID for series navigation + title: item.media.title.english || item.media.title.romaji, // Episode title not available, use series title + seriesName: item.media.title.english || item.media.title.romaji, + poster: item.media.coverImage.large || item.media.coverImage.medium, + releaseDate: new Date(item.airingAt * 1000).toISOString(), + season: 1, // AniList doesn't always provide season number easily + episode: item.episode, + overview: `Airing at ${format(date, 'HH:mm')}`, + vote_average: 0, + still_path: null, + season_poster_path: null, + day: dayName, + time: format(date, 'HH:mm'), + genres: [item.media.format] // Use format as genre for now + }; + + grouped[dayName].push(episode); + }); + + // Sort sections starting from today + const todayIndex = new Date().getDay(); // 0 = Sunday + const sortedSections: CalendarSection[] = []; + + for (let i = 0; i < 7; i++) { + const dayIndex = (todayIndex + i) % 7; + const dayName = daysOrder[dayIndex]; + if (grouped[dayName] && grouped[dayName].length > 0) { + sortedSections.push({ + title: i === 0 ? 'Today' : (i === 1 ? 'Tomorrow' : dayName), + data: grouped[dayName].sort((a, b) => (a.time || '').localeCompare(b.time || '')) + }); + } + } + + setAniListSchedule(sortedSections); + } catch (e) { + logger.error('Failed to load AniList schedule', e); + } finally { + setAniListLoading(false); + } + }, []); + + useEffect(() => { + if (calendarSource === 'anilist' && aniListSchedule.length === 0) { + fetchAniListSchedule(); + } + }, [calendarSource]); const onRefresh = useCallback(() => { setRefreshing(true); // Check memory pressure before refresh memoryManager.checkMemoryPressure(); - refresh(true); + if (calendarSource === 'nuvio') { + refresh(true); + } else { + fetchAniListSchedule(); + } setRefreshing(false); - }, [refresh]); + }, [refresh, calendarSource, fetchAniListSchedule]); // Defer heavy UI work until after interactions to reduce jank/crashes useEffect(() => { @@ -115,20 +174,22 @@ const CalendarScreen = () => { episodeId }); }, [navigation, handleSeriesPress]); - + const renderEpisodeItem = ({ item }: { item: CalendarEpisode }) => { const hasReleaseDate = !!item.releaseDate; - const releaseDate = hasReleaseDate ? parseISO(item.releaseDate) : null; + const releaseDate = hasReleaseDate && item.releaseDate ? parseISO(item.releaseDate) : null; const formattedDate = releaseDate ? format(releaseDate, 'MMM d, yyyy') : ''; const isFuture = releaseDate ? isAfter(releaseDate, new Date()) : false; + const isAnimeItem = item.id.startsWith('mal:') || item.id.startsWith('kitsu:'); // Use episode still image if available, fallback to series poster + // For AniList items, item.poster is already a full URL const imageUrl = item.still_path ? tmdbService.getImageUrl(item.still_path) : (item.season_poster_path ? tmdbService.getImageUrl(item.season_poster_path) : item.poster); - + return ( { > - + {item.seriesName} - {hasReleaseDate ? ( + {(hasReleaseDate || isAnimeItem) ? ( <> - - S{item.season}:E{item.episode} - {item.title} - + {!isAnimeItem && ( + + S{item.season}:E{item.episode} - {item.title} + + )} {item.overview ? ( - + {item.overview} ) : null} + + {isAnimeItem && item.genres && item.genres.length > 0 && ( + + {item.genres.slice(0, 3).map((g, i) => ( + + {g} + + ))} + + )} - {formattedDate} + + {isAnimeItem ? `${item.day} ${item.time || ''}` : formattedDate} + {item.vote_average > 0 && ( @@ -179,9 +257,9 @@ const CalendarScreen = () => { - + {item.vote_average.toFixed(1)} @@ -231,18 +309,38 @@ const CalendarScreen = () => { ); }; + + const renderSourceSwitcher = () => ( + + setCalendarSource('nuvio')} + > + Nuvio + + setCalendarSource('anilist')} + > + AniList + + + ); // Process all episodes once data is loaded - using memory-efficient approach const allEpisodes = React.useMemo(() => { if (!uiReady) return [] as CalendarEpisode[]; - const episodes = calendarData.reduce((acc: CalendarEpisode[], section: CalendarSection) => { - + // Use AniList schedule if selected + const sourceData = calendarSource === 'anilist' ? aniListSchedule : calendarData; + + const episodes = sourceData.reduce((acc: CalendarEpisode[], section: CalendarSection) => { + // Pre-trim section arrays defensively const trimmed = memoryManager.limitArraySize(section.data.filter(ep => ep.season !== 0), 500); return acc.length > 1500 ? acc : [...acc, ...trimmed]; }, [] as CalendarEpisode[]); // Global cap to keep memory bounded return memoryManager.limitArraySize(episodes, 1500); - }, [calendarData, uiReady]); + }, [calendarData, aniListSchedule, uiReady, calendarSource]); // Log when rendering with relevant state info logger.log(`[Calendar] Rendering: loading=${loading}, calendarData sections=${calendarData.length}, allEpisodes=${allEpisodes.length}`); @@ -284,7 +382,7 @@ const CalendarScreen = () => { setFilteredEpisodes([]); }, []); - if ((loading || !uiReady) && !refreshing) { + if (((loading || aniListLoading) || !uiReady) && !refreshing) { return ( @@ -310,7 +408,11 @@ const CalendarScreen = () => { {t('calendar.title')} + + {renderSourceSwitcher()} + {calendarSource === 'nuvio' && ( + <> {selectedDate && filteredEpisodes.length > 0 && ( @@ -326,6 +428,8 @@ const CalendarScreen = () => { episodes={allEpisodes} onSelectDate={handleDateSelect} /> + + )} {selectedDate && filteredEpisodes.length > 0 ? ( { - ) : calendarData.length > 0 ? ( + ) : (calendarSource === 'anilist' ? aniListSchedule : calendarData).length > 0 ? ( item.id} renderItem={renderEpisodeItem} renderSectionHeader={renderSectionHeader} @@ -560,6 +664,41 @@ const styles = StyleSheet.create({ fontSize: 14, marginBottom: 4, }, + tabContainer: { + flexDirection: 'row', + marginVertical: 12, + paddingHorizontal: 16, + gap: 12, + }, + tabButton: { + flex: 1, + paddingVertical: 10, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + tabText: { + fontSize: 14, + fontWeight: '600', + color: 'rgba(255, 255, 255, 0.7)', + }, + genreContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + marginTop: 6, + }, + genreChip: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 4, + }, + genreText: { + fontSize: 10, + fontWeight: '700', + textTransform: 'uppercase', + }, }); export default CalendarScreen; \ No newline at end of file diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 89608385..79b0d2ff 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -246,6 +246,10 @@ const SkeletonLoader = () => { ); }; +import { MalApiService, MalSync, MalAnimeNode } from '../services/mal'; + +// ... other imports + const LibraryScreen = () => { const { t } = useTranslation(); const navigation = useNavigation>(); @@ -254,8 +258,12 @@ const LibraryScreen = () => { const { numColumns, itemWidth } = useMemo(() => getGridLayout(width), [width]); const [loading, setLoading] = useState(true); const [libraryItems, setLibraryItems] = useState([]); - const [filter, setFilter] = useState<'trakt' | 'simkl' | 'movies' | 'series'>('movies'); + const [filter, setFilter] = useState<'trakt' | 'simkl' | 'movies' | 'series' | 'mal'>('movies'); const [showTraktContent, setShowTraktContent] = useState(false); + const [malList, setMalMalList] = useState([]); + const [malLoading, setMalLoading] = useState(false); + const [malOffset, setMalOffset] = useState(0); + const [hasMoreMal, setHasMoreMal] = useState(true); const [selectedTraktFolder, setSelectedTraktFolder] = useState(null); const [showSimklContent, setShowSimklContent] = useState(false); const [selectedSimklFolder, setSelectedSimklFolder] = useState(null); @@ -1473,6 +1481,68 @@ const LibraryScreen = () => { ); }; + const loadMalList = useCallback(async (isLoadMore = false) => { + if (malLoading || (isLoadMore && !hasMoreMal)) return; + + const currentOffset = isLoadMore ? malOffset : 0; + setMalLoading(true); + try { + const response = await MalApiService.getUserList(undefined, currentOffset, 100); + if (isLoadMore) { + setMalMalList(prev => [...prev, ...response.data]); + } else { + setMalMalList(response.data); + } + setMalOffset(currentOffset + response.data.length); + setHasMoreMal(!!response.paging.next); + } catch (error) { + logger.error('Failed to load MAL list:', error); + } finally { + setMalLoading(false); + } + }, [malLoading, malOffset, hasMoreMal]); + + const renderMalItem = ({ item }: { item: MalAnimeNode }) => ( + navigation.navigate('Metadata', { + id: `mal:${item.node.id}`, + type: item.node.media_type === 'movie' ? 'movie' : 'series' + })} + activeOpacity={0.7} + > + + + + + {item.list_status.status.replace('_', ' ')} + + + + + + + {item.node.title} + + + ★ {item.list_status.score > 0 ? item.list_status.score : '-'} + + + + ); + const renderSimklCollectionFolder = ({ folder }: { folder: TraktFolder }) => ( { ); + const renderMalContent = () => { + if (malLoading && malList.length === 0) return ; + + if (malList.length === 0) { + return ( + + + Your MAL list is empty + loadMalList()} + > + Refresh + + + ); + } + + const grouped = { + watching: malList.filter(i => i.list_status.status === 'watching'), + plan_to_watch: malList.filter(i => i.list_status.status === 'plan_to_watch'), + completed: malList.filter(i => i.list_status.status === 'completed'), + dropped: malList.filter(i => i.list_status.status === 'dropped'), + on_hold: malList.filter(i => i.list_status.status === 'on_hold'), + }; + + const sections = [ + { key: 'watching', title: 'Watching', data: grouped.watching }, + { key: 'plan_to_watch', title: 'Plan to Watch', data: grouped.plan_to_watch }, + { key: 'completed', title: 'Completed', data: grouped.completed }, + { key: 'dropped', title: 'Dropped', data: grouped.dropped }, + { key: 'on_hold', title: 'On Hold', data: grouped.on_hold }, + ]; + + return ( + { + if (isCloseToBottom(nativeEvent) && hasMoreMal) { + loadMalList(true); + } + }} + scrollEventThrottle={400} + > + {sections.map(section => ( + section.data.length > 0 && ( + + + {section.title} ({section.data.length}) + + + {section.data.map(item => ( + + {renderMalItem({ item })} + + ))} + + + ) + ))} + {malLoading && ( + + )} + + ); + }; + + const isCloseToBottom = ({ layoutMeasurement, contentOffset, contentSize }: any) => { + const paddingToBottom = 20; + return layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom; + }; + const renderSimklContent = () => { if (simklLoading) { return ( @@ -1599,7 +1742,7 @@ const LibraryScreen = () => { ); }; - const renderFilter = (filterType: 'trakt' | 'simkl' | 'movies' | 'series', label: string) => { + const renderFilter = (filterType: 'trakt' | 'simkl' | 'movies' | 'series' | 'mal', label: string, iconName?: keyof typeof MaterialIcons.glyphMap) => { const isActive = filter === filterType; return ( @@ -1622,7 +1765,7 @@ const LibraryScreen = () => { } if (filterType === 'simkl') { if (!simklAuthenticated) { - navigation.navigate('Settings'); + navigation.navigate('SimklSettings'); } else { setShowSimklContent(true); setSelectedSimklFolder(null); @@ -1630,10 +1773,19 @@ const LibraryScreen = () => { } return; } + if (filterType === 'mal') { + navigation.navigate('MalLibrary'); + return; + } + setShowTraktContent(false); + setShowSimklContent(false); setFilter(filterType); }} activeOpacity={0.7} > + {iconName && ( + + )} { ); }; + const renderContent = () => { if (loading) { return ; @@ -1742,15 +1895,21 @@ const LibraryScreen = () => { {!showTraktContent && !showSimklContent && ( - - {renderFilter('trakt', 'Trakt')} - {renderFilter('simkl', 'SIMKL')} - {renderFilter('movies', t('search.movies'))} - {renderFilter('series', t('search.tv_shows'))} - + + {renderFilter('trakt', 'Trakt', 'pan-tool')} + {renderFilter('simkl', 'SIMKL', 'video-library')} + {renderFilter('mal', 'MAL', 'book')} + {renderFilter('movies', t('search.movies'), 'movie')} + {renderFilter('series', t('search.tv_shows'), 'live-tv')} + )} - {showTraktContent ? renderTraktContent() : showSimklContent ? renderSimklContent() : renderContent()} + {showTraktContent ? renderTraktContent() : showSimklContent ? renderSimklContent() : (filter === 'mal' ? renderMalContent() : renderContent())} {/* Sync FAB - Bottom Right (only in manual mode) */} @@ -1848,15 +2007,18 @@ const styles = StyleSheet.create({ flex: 1, }, filtersContainer: { - flexDirection: 'row', - justifyContent: 'center', - paddingHorizontal: 16, - paddingBottom: 16, - paddingTop: 8, + flexGrow: 0, borderBottomWidth: 1, borderBottomColor: 'rgba(255,255,255,0.05)', zIndex: 10, }, + filtersContent: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingBottom: 16, + paddingTop: 8, + }, filterButton: { flexDirection: 'row', alignItems: 'center', @@ -2121,6 +2283,41 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, + malBadge: { + position: 'absolute', + top: 8, + left: 8, + backgroundColor: 'rgba(0, 0, 0, 0.7)', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + }, + malBadgeText: { + color: '#fff', + fontSize: 10, + fontWeight: 'bold', + textTransform: 'uppercase', + }, + malScore: { + fontSize: 12, + fontWeight: 'bold', + marginTop: 2, + textAlign: 'center', + }, + malSectionContainer: { + marginBottom: 24, + }, + malSectionHeader: { + fontSize: 18, + fontWeight: '700', + marginBottom: 12, + paddingHorizontal: 4, + }, + malSectionGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + }, syncFab: { position: 'absolute', right: 16, diff --git a/src/screens/MalLibraryScreen.tsx b/src/screens/MalLibraryScreen.tsx new file mode 100644 index 00000000..6acc5bfb --- /dev/null +++ b/src/screens/MalLibraryScreen.tsx @@ -0,0 +1,318 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + FlatList, + ActivityIndicator, + SafeAreaView, + StatusBar, + Platform, + Dimensions, + RefreshControl, + ScrollView, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { MaterialIcons } from '@expo/vector-icons'; +import FastImage from '@d11/react-native-fast-image'; +import { MalApiService } from '../services/mal/MalApi'; +import { MalAnimeNode, MalListStatus } from '../types/mal'; +import { useTheme } from '../contexts/ThemeContext'; +import { useTranslation } from 'react-i18next'; +import { logger } from '../utils/logger'; +import { MalEditModal } from '../components/mal/MalEditModal'; +import { MalSync } from '../services/mal/MalSync'; + +const { width } = Dimensions.get('window'); +const ITEM_WIDTH = width * 0.35; +const ITEM_HEIGHT = ITEM_WIDTH * 1.5; + +const MalLibraryScreen: React.FC = () => { + const { t } = useTranslation(); + const navigation = useNavigation(); + const { currentTheme } = useTheme(); + + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [groupedList, setGroupedList] = useState>({ + watching: [], + completed: [], + on_hold: [], + dropped: [], + plan_to_watch: [], + }); + + const [selectedAnime, setSelectedAnime] = useState(null); + const [isEditModalVisible, setIsEditModalVisible] = useState(false); + + const fetchMalList = useCallback(async () => { + try { + setIsLoading(true); + + let allItems: MalAnimeNode[] = []; + let offset = 0; + let hasMore = true; + + while (hasMore && offset < 1000) { + const response = await MalApiService.getUserList(undefined, offset, 100); + if (response.data && response.data.length > 0) { + allItems = [...allItems, ...response.data]; + offset += response.data.length; + hasMore = !!response.paging.next; + } else { + hasMore = false; + } + } + + const grouped: Record = { + watching: [], + completed: [], + on_hold: [], + dropped: [], + plan_to_watch: [], + }; + + allItems.forEach(item => { + const status = item.list_status.status; + if (grouped[status]) { + grouped[status].push(item); + } + }); + + setGroupedList(grouped); + } catch (error) { + logger.error('[MalLibrary] Failed to fetch list', error); + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, []); + + useEffect(() => { + fetchMalList(); + }, [fetchMalList]); + + const handleRefresh = () => { + setIsRefreshing(true); + fetchMalList(); + }; + + const handleItemPress = async (item: MalAnimeNode) => { + // Requirement 8: Resolve correct Cinemata / TMDB / IMDb ID + const malId = item.node.id; + + // Use MalSync API to get external IDs + const { imdbId } = await MalSync.getIdsFromMalId(malId); + + if (imdbId) { + navigation.navigate('Metadata', { + id: imdbId, + type: item.node.media_type === 'movie' ? 'movie' : 'series' + }); + } else { + // Fallback: Navigate to Search with the title if ID mapping is missing + logger.warn(`[MalLibrary] Could not resolve IMDb ID for MAL:${malId}. Falling back to Search.`); + navigation.navigate('Search', { query: item.node.title }); + } + }; + + const renderAnimeItem = ({ item }: { item: MalAnimeNode }) => ( + handleItemPress(item)} + activeOpacity={0.7} + > + + + + + {item.list_status.num_episodes_watched} / {item.node.num_episodes || '?'} + + + + + {item.node.title} + + {item.list_status.score > 0 && ( + + + + {item.list_status.score} + + + )} + + {/* Requirement 5: Manual update button */} + { + setSelectedAnime(item); + setIsEditModalVisible(true); + }} + > + + + + ); + + const renderSection = (status: MalListStatus, title: string, icon: string) => { + const data = groupedList[status]; + if (data.length === 0) return null; + + return ( + + + + + {title} ({data.length}) + + + item.node.id.toString()} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.carouselContent} + snapToInterval={ITEM_WIDTH + 12} + decelerationRate="fast" + /> + + ); + }; + + return ( + + + + + navigation.goBack()} style={styles.backButton}> + + + + MyAnimeList + + {/* Requirement 6: Manual Sync Button */} + + {isLoading ? ( + + ) : ( + + )} + + + + {!isLoading || isRefreshing ? ( + + } + contentContainerStyle={{ paddingBottom: 40 }} + > + {renderSection('watching', 'Watching', 'play-circle-outline')} + {renderSection('plan_to_watch', 'Plan to Watch', 'bookmark-outline')} + {renderSection('completed', 'Completed', 'check-circle-outline')} + {renderSection('on_hold', 'On Hold', 'pause-circle-outline')} + {renderSection('dropped', 'Dropped', 'highlight-off')} + + ) : ( + + + + )} + + {selectedAnime && ( + { + setIsEditModalVisible(false); + setSelectedAnime(null); + }} + onUpdateSuccess={fetchMalList} + /> + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1 }, + safeArea: { flex: 1, paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0 }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + justifyContent: 'space-between' + }, + backButton: { padding: 4 }, + headerTitle: { fontSize: 20, fontWeight: '700', flex: 1, marginLeft: 16 }, + syncButton: { padding: 4 }, + loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + sectionContainer: { marginVertical: 12 }, + sectionHeader: { paddingHorizontal: 16, marginBottom: 8 }, + sectionTitle: { fontSize: 18, fontWeight: '700' }, + carouselContent: { paddingHorizontal: 10 }, + animeItem: { + width: ITEM_WIDTH, + marginHorizontal: 6, + marginBottom: 10, + }, + poster: { + width: ITEM_WIDTH, + height: ITEM_HEIGHT, + borderRadius: 8, + backgroundColor: '#333', + }, + badgeContainer: { + position: 'absolute', + top: 6, + left: 6, + }, + episodeBadge: { + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + }, + episodeText: { + color: 'white', + fontSize: 10, + fontWeight: '700', + }, + animeTitle: { + fontSize: 12, + fontWeight: '600', + marginTop: 6, + lineHeight: 16, + }, + scoreRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 2, + }, + scoreText: { + fontSize: 11, + marginLeft: 4, + }, + editButton: { + position: 'absolute', + top: 6, + right: 6, + backgroundColor: 'rgba(0,0,0,0.6)', + padding: 6, + borderRadius: 15, + } +}); + +export default MalLibraryScreen; diff --git a/src/screens/MalSettingsScreen.tsx b/src/screens/MalSettingsScreen.tsx new file mode 100644 index 00000000..15f1fa1c --- /dev/null +++ b/src/screens/MalSettingsScreen.tsx @@ -0,0 +1,555 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + ActivityIndicator, + SafeAreaView, + ScrollView, + StatusBar, + Platform, + Switch, + Image, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; +import FastImage from '@d11/react-native-fast-image'; +import { MalAuth } from '../services/mal/MalAuth'; +import { MalApiService } from '../services/mal/MalApi'; +import { MalSync } from '../services/mal/MalSync'; +import { mmkvStorage } from '../services/mmkvStorage'; +import { MalUser } from '../types/mal'; +import { useTheme } from '../contexts/ThemeContext'; +import { colors } from '../styles'; +import CustomAlert from '../components/CustomAlert'; +import { useTranslation } from 'react-i18next'; + +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; + +const MalSettingsScreen: React.FC = () => { + const { t } = useTranslation(); + const navigation = useNavigation(); + const { currentTheme } = useTheme(); + + const [isLoading, setIsLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [userProfile, setUserProfile] = useState(null); + + const [syncEnabled, setSyncEnabled] = useState(mmkvStorage.getBoolean('mal_enabled') ?? true); + const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(mmkvStorage.getBoolean('mal_auto_update') ?? true); + const [autoAddEnabled, setAutoAddEnabled] = useState(mmkvStorage.getBoolean('mal_auto_add') ?? true); + const [autoLibrarySyncEnabled, setAutoLibrarySyncEnabled] = useState(mmkvStorage.getBoolean('mal_auto_sync_to_library') ?? false); + const [includeNsfwEnabled, setIncludeNsfwEnabled] = useState(mmkvStorage.getBoolean('mal_include_nsfw') ?? true); + + const [alertVisible, setAlertVisible] = useState(false); + const [alertTitle, setAlertTitle] = useState(''); + const [alertMessage, setAlertMessage] = useState(''); + const [alertActions, setAlertActions] = useState void }>>([]); + + const openAlert = (title: string, message: string, actions?: any[]) => { + setAlertTitle(title); + setAlertMessage(message); + setAlertActions(actions || [{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]); + setAlertVisible(true); + }; + + const checkAuthStatus = useCallback(async () => { + setIsLoading(true); + try { + // Initialize Auth (loads from storage) + const token = MalAuth.getToken(); + + if (token && !MalAuth.isTokenExpired(token)) { + setIsAuthenticated(true); + // Fetch Profile + const profile = await MalApiService.getUserInfo(); + setUserProfile(profile); + } else if (token && MalAuth.isTokenExpired(token)) { + // Try refresh + const refreshed = await MalAuth.refreshToken(); + if (refreshed) { + setIsAuthenticated(true); + const profile = await MalApiService.getUserInfo(); + setUserProfile(profile); + } else { + setIsAuthenticated(false); + setUserProfile(null); + } + } else { + setIsAuthenticated(false); + setUserProfile(null); + } + } catch (error) { + console.error('[MalSettings] Auth check failed', error); + setIsAuthenticated(false); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + checkAuthStatus(); + }, [checkAuthStatus]); + + const handleSignIn = async () => { + setIsLoading(true); + try { + const result = await MalAuth.login(); + if (result === true) { + await checkAuthStatus(); + openAlert('Success', 'Connected to MyAnimeList'); + } else { + const errorMessage = typeof result === 'string' ? result : 'Failed to connect to MyAnimeList'; + openAlert('Error', errorMessage); + } + } catch (e: any) { + console.error(e); + openAlert('Error', `An error occurred during sign in: ${e.message || 'Unknown error'}`); + } finally { + setIsLoading(false); + } + }; + + const handleSignOut = () => { + openAlert('Sign Out', 'Are you sure you want to disconnect?', [ + { label: 'Cancel', onPress: () => setAlertVisible(false) }, + { + label: 'Sign Out', + onPress: () => { + MalAuth.clearToken(); + setIsAuthenticated(false); + setUserProfile(null); + setAlertVisible(false); + } + } + ]); + }; + + const toggleSync = (val: boolean) => { + setSyncEnabled(val); + mmkvStorage.setBoolean('mal_enabled', val); + }; + + const toggleAutoUpdate = (val: boolean) => { + setAutoUpdateEnabled(val); + mmkvStorage.setBoolean('mal_auto_update', val); + }; + + const toggleAutoAdd = (val: boolean) => { + setAutoAddEnabled(val); + mmkvStorage.setBoolean('mal_auto_add', val); + }; + + const toggleAutoLibrarySync = (val: boolean) => { + setAutoLibrarySyncEnabled(val); + mmkvStorage.setBoolean('mal_auto_sync_to_library', val); + }; + + const toggleIncludeNsfw = (val: boolean) => { + setIncludeNsfwEnabled(val); + mmkvStorage.setBoolean('mal_include_nsfw', val); + }; + + return ( + + + + navigation.goBack()} + style={styles.backButton} + > + + + Settings + + + + + + MyAnimeList + + + + + {isLoading ? ( + + + + ) : isAuthenticated && userProfile ? ( + + + {userProfile.picture ? ( + + ) : ( + + {userProfile.name.charAt(0)} + + )} + + + {userProfile.name} + + + + + ID: {userProfile.id} + + + {userProfile.location && ( + + + + {userProfile.location} + + + )} + {userProfile.birthday && ( + + + + {userProfile.birthday} + + + )} + + + + {userProfile.anime_statistics && ( + + + + + {userProfile.anime_statistics.num_items} + + Total + + + + {userProfile.anime_statistics.num_days_watched.toFixed(1)} + + Days + + + + {userProfile.anime_statistics.mean_score.toFixed(1)} + + Mean + + + + + + + Watching + + {userProfile.anime_statistics.num_items_watching} + + + + + Completed + + {userProfile.anime_statistics.num_items_completed} + + + + + On Hold + + {userProfile.anime_statistics.num_items_on_hold} + + + + + Dropped + + {userProfile.anime_statistics.num_items_dropped} + + + + + )} + + + { + setIsLoading(true); + try { + const synced = await MalSync.syncMalToLibrary(); + if (synced) { + openAlert('Sync Complete', 'MAL data has been refreshed.'); + } else { + openAlert('Sync Failed', 'Could not refresh MAL data.'); + } + } catch { + openAlert('Sync Failed', 'Could not refresh MAL data.'); + } finally { + setIsLoading(false); + } + }} + > + + Sync + + + + Sign Out + + + + ) : ( + + + + Connect MyAnimeList + + + Sync your watch history and manage your anime list. + + + Sign In with MAL + + + )} + + + {isAuthenticated && ( + + + + Sync Settings + + + + + + + Enable MAL Sync + + + Global switch to enable or disable all MyAnimeList features. + + + + + + + + + + + Auto Episode Update + + + Automatically update your progress on MAL when you finish watching an episode (>=90% completion). + + + + + + + + + + + Auto Add Anime + + + If an anime is not in your MAL list, it will be added automatically when you start watching. + + + + + + + + + + + Auto-Sync to Library + + + Automatically add items from your MAL 'Watching' list to your Nuvio Library. + + + + + + + + + + + Include NSFW Content + + + Allow NSFW entries to be returned when fetching your MAL list. + + + + + + + + )} + + + setAlertVisible(false)} + actions={alertActions} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1 }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, + }, + backButton: { flexDirection: 'row', alignItems: 'center', padding: 8 }, + backText: { fontSize: 17, marginLeft: 8 }, + headerTitle: { + fontSize: 34, + fontWeight: 'bold', + paddingHorizontal: 16, + marginBottom: 24, + }, + scrollView: { flex: 1 }, + scrollContent: { paddingHorizontal: 16, paddingBottom: 32 }, + card: { + borderRadius: 12, + overflow: 'hidden', + marginBottom: 16, + elevation: 2, + }, + loadingContainer: { padding: 40, alignItems: 'center' }, + signInContainer: { padding: 24, alignItems: 'center' }, + signInTitle: { fontSize: 20, fontWeight: '600', marginBottom: 8 }, + signInDescription: { fontSize: 15, textAlign: 'center', marginBottom: 24 }, + button: { + width: '100%', + height: 44, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + marginTop: 8, + }, + buttonText: { fontSize: 16, fontWeight: '500', color: 'white' }, + profileContainer: { padding: 20 }, + profileHeader: { flexDirection: 'row', alignItems: 'center' }, + avatar: { width: 64, height: 64, borderRadius: 32 }, + avatarPlaceholder: { width: 64, height: 64, borderRadius: 32, alignItems: 'center', justifyContent: 'center' }, + avatarText: { fontSize: 24, color: 'white', fontWeight: 'bold' }, + profileInfo: { marginLeft: 16, flex: 1 }, + profileName: { fontSize: 18, fontWeight: '600' }, + profileDetailRow: { flexDirection: 'row', alignItems: 'center', marginTop: 2 }, + profileDetailText: { fontSize: 12, marginLeft: 4 }, + statsContainer: { marginTop: 20 }, + statsRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 16 }, + statBox: { alignItems: 'center', flex: 1 }, + statValue: { fontSize: 18, fontWeight: 'bold' }, + statLabel: { fontSize: 12, marginTop: 2 }, + statGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + borderTopWidth: 1, + paddingTop: 16, + gap: 12 + }, + statGridItem: { + flexDirection: 'row', + alignItems: 'center', + width: '45%', + marginBottom: 8 + }, + statusDot: { width: 8, height: 8, borderRadius: 4, marginRight: 8 }, + statGridLabel: { fontSize: 13, flex: 1 }, + statGridValue: { fontSize: 13, fontWeight: '600' }, + actionButtonsRow: { flexDirection: 'row', marginTop: 20 }, + smallButton: { + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + }, + signOutButton: { marginTop: 20 }, + settingsSection: { padding: 20 }, + sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 16 }, + settingItem: { marginBottom: 16 }, + settingContent: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + settingTextContainer: { flex: 1, marginRight: 16 }, + settingLabel: { fontSize: 15, fontWeight: '500', marginBottom: 4 }, + settingDescription: { fontSize: 14 }, + noteContainer: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + borderRadius: 8, + borderWidth: 1, + marginBottom: 20, + marginTop: -8, + }, + noteText: { + fontSize: 13, + marginLeft: 8, + flex: 1, + lineHeight: 18, + }, +}); + +export default MalSettingsScreen; diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index c47a2e6b..c2538a04 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -85,17 +85,15 @@ const MemoizedRatingsSection = memo(RatingsSection); const MemoizedCommentsSection = memo(CommentsSection); const MemoizedCastDetailsModal = memo(CastDetailsModal); +// ... other imports + const MetadataScreen: React.FC = () => { - const route = useRoute, string>>(); + useEffect(() => { console.log('✅ MetadataScreen MOUNTED'); }, []); const navigation = useNavigation>(); + const route = useRoute>(); const { id, type, episodeId, addonId } = route.params; const { t } = useTranslation(); - // Log route parameters for debugging - React.useEffect(() => { - console.log('🔍 [MetadataScreen] Route params:', { id, type, episodeId, addonId }); - }, [id, type, episodeId, addonId]); - // Consolidated hooks for better performance const { settings } = useSettings(); const { currentTheme } = useTheme(); @@ -105,6 +103,35 @@ const MetadataScreen: React.FC = () => { // Trakt integration const { isAuthenticated, isInWatchlist, isInCollection, addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection } = useTraktContext(); + const { + metadata, + loading, + error: metadataError, + cast, + loadingCast, + episodes, + selectedSeason, + loadingSeasons, + loadMetadata, + handleSeasonChange, + toggleLibrary, + inLibrary, + groupedEpisodes, + recommendations, + loadingRecommendations, + setMetadata, + imdbId, + tmdbId, + collectionMovies, + loadingCollection, + } = useMetadata({ id, type, addonId }); + + const [selectedCastMember, setSelectedCastMember] = useState(null); + const [shouldLoadSecondaryData, setShouldLoadSecondaryData] = useState(false); + const [isScreenFocused, setIsScreenFocused] = useState(true); + const [isContentReady, setIsContentReady] = useState(false); + const [showCastModal, setShowCastModal] = useState(false); + // Enhanced responsive sizing for tablets and TV screens const deviceWidth = Dimensions.get('window').width; const deviceHeight = Dimensions.get('window').height; @@ -137,12 +164,6 @@ const MetadataScreen: React.FC = () => { } }, [deviceType]); - // Optimized state management - reduced state variables - const [isContentReady, setIsContentReady] = useState(false); - const [showCastModal, setShowCastModal] = useState(false); - const [selectedCastMember, setSelectedCastMember] = useState(null); - const [shouldLoadSecondaryData, setShouldLoadSecondaryData] = useState(false); - const [isScreenFocused, setIsScreenFocused] = useState(true); // Source switching removed const transitionOpacity = useSharedValue(1); const interactionComplete = useRef(false); @@ -170,30 +191,6 @@ const MetadataScreen: React.FC = () => { console.log('MetadataScreen: selectedComment changed to:', selectedComment?.id); }, [selectedComment]); - const { - metadata, - loading, - error: metadataError, - cast, - loadingCast, - episodes, - selectedSeason, - loadingSeasons, - loadMetadata, - handleSeasonChange, - toggleLibrary, - inLibrary, - groupedEpisodes, - recommendations, - loadingRecommendations, - setMetadata, - imdbId, - tmdbId, - collectionMovies, - loadingCollection, - } = useMetadata({ id, type, addonId }); - - // Log useMetadata hook state changes for debugging React.useEffect(() => { console.log('🔍 [MetadataScreen] useMetadata state:', { @@ -896,25 +893,13 @@ const MetadataScreen: React.FC = () => { // Show error if exists if (metadataError || (!loading && !metadata)) { - console.log('🔍 [MetadataScreen] Showing error component:', { - hasError: !!metadataError, - errorMessage: metadataError, - isLoading: loading, - hasMetadata: !!metadata, - loadingState: loading - }); + console.log('❌ MetadataScreen ERROR state:', { metadataError, loading, hasMetadata: !!metadata }); return ErrorComponent; } // Show loading screen if metadata is not yet available or exit animation hasn't completed if (loading || !isContentReady || !loadingScreenExited) { - console.log('🔍 [MetadataScreen] Showing loading screen:', { - isLoading: loading, - isContentReady, - loadingScreenExited, - hasMetadata: !!metadata, - errorMessage: metadataError - }); + console.log('⏳ MetadataScreen LOADING state:', { loading, isContentReady, loadingScreenExited, hasMetadata: !!metadata }); return ( { const { t } = useTranslation(); const { settings } = useSettings(); const navigation = useNavigation>(); + const route = useRoute(); const { addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection, isInWatchlist, isInCollection } = useTraktContext(); const { showSuccess, showInfo } = useToast(); const [query, setQuery] = useState(''); @@ -153,18 +154,13 @@ const SearchScreen = () => { type: catalog.type, }; await mmkvStorage.setItem(DISCOVER_CATALOG_KEY, JSON.stringify(catalogData)); - } else { - // Clear catalog if null - await mmkvStorage.removeItem(DISCOVER_CATALOG_KEY); } - // Save genre - use empty string to indicate "All genres" - // This way we distinguish between "not set" and "All genres" + // Save genre if (genre) { await mmkvStorage.setItem(DISCOVER_GENRE_KEY, genre); } else { - // Save empty string to indicate "All genres" is selected - await mmkvStorage.setItem(DISCOVER_GENRE_KEY, ''); + await mmkvStorage.removeItem(DISCOVER_GENRE_KEY); } } catch (error) { logger.error('Failed to save discover settings:', error); @@ -193,21 +189,11 @@ const SearchScreen = () => { // Load saved genre const savedGenre = await mmkvStorage.getItem(DISCOVER_GENRE_KEY); - if (savedGenre !== null) { - if (savedGenre === '') { - // Empty string means "All genres" was selected - setSelectedDiscoverGenre(null); - } else if (foundCatalog.genres.includes(savedGenre)) { - setSelectedDiscoverGenre(savedGenre); - } else if (foundCatalog.genres.length > 0) { - // Set first genre as default if saved genre not available - setSelectedDiscoverGenre(foundCatalog.genres[0]); - } - } else { - // No saved genre, default to first genre - if (foundCatalog.genres.length > 0) { - setSelectedDiscoverGenre(foundCatalog.genres[0]); - } + if (savedGenre && foundCatalog.genres.includes(savedGenre)) { + setSelectedDiscoverGenre(savedGenre); + } else if (foundCatalog.genres.length > 0) { + // Set first genre as default if saved genre not available + setSelectedDiscoverGenre(foundCatalog.genres[0]); } return; } @@ -484,6 +470,13 @@ const SearchScreen = () => { useFocusEffect( useCallback(() => { isMounted.current = true; + + // Check for route query param + if (route.params?.query && route.params.query !== query) { + setQuery(route.params.query); + // The query effect will trigger debouncedSearch automatically + } + return () => { isMounted.current = false; if (liveSearchHandle.current) { @@ -492,7 +485,7 @@ const SearchScreen = () => { } debouncedSearch.cancel(); }; - }, [debouncedSearch]) + }, [debouncedSearch, route.params?.query]) ); const performLiveSearch = async (searchQuery: string) => { @@ -703,7 +696,7 @@ const SearchScreen = () => { const handleGenreSelect = (genre: string | null) => { setSelectedDiscoverGenre(genre); - // Save genre setting - this will save empty string for null (All genres) + // Save genre setting saveDiscoverSettings(selectedDiscoverType, selectedCatalog, genre); genreSheetRef.current?.dismiss(); diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 1e8f5bd6..eee4857a 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -12,6 +12,7 @@ import { Platform, Dimensions, FlatList, + Image, } from 'react-native'; import { BottomSheetModal, BottomSheetView, BottomSheetBackdrop, BottomSheetScrollView } from '@gorhom/bottom-sheet'; import { useTranslation } from 'react-i18next'; @@ -391,7 +392,7 @@ const SettingsScreen: React.FC = () => { } renderControl={() => } onPress={() => (navigation as any).navigate('SyncSettings')} - isLast={!showTraktItem && !showSimklItem} + isLast={!showTraktItem && !showSimklItem && !isItemVisible('mal')} isTablet={isTablet} /> )} @@ -402,7 +403,7 @@ const SettingsScreen: React.FC = () => { customIcon={} renderControl={() => } onPress={() => navigation.navigate('TraktSettings')} - isLast={!showSimklItem} + isLast={!showSimklItem && !isItemVisible('mal')} isTablet={isTablet} /> )} @@ -413,6 +414,17 @@ const SettingsScreen: React.FC = () => { customIcon={} renderControl={() => } onPress={() => navigation.navigate('SimklSettings')} + isLast={!isItemVisible('mal')} + isTablet={isTablet} + /> + )} + {isItemVisible('mal') && ( + } + renderControl={() => } + onPress={() => navigation.navigate('MalSettings')} isLast={true} isTablet={isTablet} /> @@ -701,7 +713,7 @@ const SettingsScreen: React.FC = () => { contentContainerStyle={styles.scrollContent} > {/* Account */} - {(settingsConfig?.categories?.['account']?.visible !== false) && (showTraktItem || showSimklItem || showCloudSyncItem) && ( + {(settingsConfig?.categories?.['account']?.visible !== false) && (showTraktItem || showSimklItem || showCloudSyncItem || isItemVisible('mal')) && ( {showCloudSyncItem && ( { } renderControl={() => } onPress={() => (navigation as any).navigate('SyncSettings')} - isLast={!showTraktItem && !showSimklItem} + isLast={!showTraktItem && !showSimklItem && !isItemVisible('mal')} /> )} {showTraktItem && ( @@ -726,7 +738,7 @@ const SettingsScreen: React.FC = () => { customIcon={} renderControl={() => } onPress={() => navigation.navigate('TraktSettings')} - isLast={!showSimklItem} + isLast={!showSimklItem && !isItemVisible('mal')} /> )} {showSimklItem && ( @@ -736,7 +748,17 @@ const SettingsScreen: React.FC = () => { customIcon={} renderControl={() => } onPress={() => navigation.navigate('SimklSettings')} - isLast={true} + isLast={!isItemVisible('mal')} + /> + )} + {isItemVisible('mal') && ( + } + renderControl={() => } + onPress={() => navigation.navigate('MalSettings')} + isLast /> )} diff --git a/src/screens/streams/useStreamsScreen.ts b/src/screens/streams/useStreamsScreen.ts index 62de8cb0..b95b6c13 100644 --- a/src/screens/streams/useStreamsScreen.ts +++ b/src/screens/streams/useStreamsScreen.ts @@ -364,6 +364,7 @@ export const useStreamsScreen = () => { const streamsToPass = selectedEpisode ? episodeStreams : groupedStreams; const streamName = stream.name || stream.title || 'Unnamed Stream'; const resolvedStreamProvider = streamProvider; + const releaseDate = type === 'movie' ? metadata?.released : currentEpisode?.air_date; // Save stream to cache try { @@ -432,6 +433,7 @@ export const useStreamsScreen = () => { availableStreams: streamsToPass, backdrop: metadata?.banner || bannerImage, videoType, + releaseDate, } as any); }, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams, bannerImage, settings.streamCacheTTL] @@ -646,8 +648,7 @@ export const useStreamsScreen = () => { hasDoneInitialLoadRef.current = true; try { - const stremioType = type === 'tv' ? 'series' : type; - const hasStremioProviders = await stremioService.hasStreamProviders(stremioType); + const hasStremioProviders = await stremioService.hasStreamProviders(type); const hasLocalScrapers = settings.enableLocalScrapers && (await localScraperService.hasScrapers()); const hasProviders = hasStremioProviders || hasLocalScrapers; diff --git a/src/services/anilist/AniListService.ts b/src/services/anilist/AniListService.ts new file mode 100644 index 00000000..65209411 --- /dev/null +++ b/src/services/anilist/AniListService.ts @@ -0,0 +1,83 @@ +import axios from 'axios'; +import { AniListResponse, AniListAiringSchedule } from './types'; +import { logger } from '../../utils/logger'; + +const ANILIST_API_URL = 'https://graphql.anilist.co'; + +const AIRING_SCHEDULE_QUERY = ` +query ($start: Int, $end: Int, $page: Int) { + Page(page: $page, perPage: 50) { + pageInfo { + hasNextPage + total + } + airingSchedules(airingAt_greater: $start, airingAt_lesser: $end, sort: TIME) { + id + airingAt + episode + media { + id + idMal + title { + romaji + english + native + } + coverImage { + large + medium + color + } + episodes + format + status + season + seasonYear + nextAiringEpisode { + airingAt + timeUntilAiring + episode + } + } + } + } +} +`; + +export const AniListService = { + getWeeklySchedule: async (): Promise => { + try { + const start = Math.floor(Date.now() / 1000); + const end = start + 7 * 24 * 60 * 60; // Next 7 days + + let allSchedules: AniListAiringSchedule[] = []; + let page = 1; + let hasNextPage = true; + + while (hasNextPage) { + const response = await axios.post(ANILIST_API_URL, { + query: AIRING_SCHEDULE_QUERY, + variables: { + start, + end, + page, + }, + }); + + const data = response.data.data.Page; + allSchedules = [...allSchedules, ...data.airingSchedules]; + + hasNextPage = data.pageInfo.hasNextPage; + page++; + + // Safety break to prevent infinite loops if something goes wrong + if (page > 10) break; + } + + return allSchedules; + } catch (error) { + logger.error('[AniListService] Failed to fetch weekly schedule:', error); + throw error; + } + }, +}; diff --git a/src/services/anilist/types.ts b/src/services/anilist/types.ts new file mode 100644 index 00000000..cdb74d43 --- /dev/null +++ b/src/services/anilist/types.ts @@ -0,0 +1,44 @@ +export interface AniListAiringSchedule { + id: number; + airingAt: number; // UNIX timestamp + episode: number; + media: { + id: number; + idMal: number | null; + title: { + romaji: string; + english: string | null; + native: string; + }; + coverImage: { + large: string; + medium: string; + color: string | null; + }; + episodes: number | null; + format: string; // TV, MOVIE, OVA, ONA, etc. + status: string; + season: string | null; + seasonYear: number | null; + nextAiringEpisode: { + airingAt: number; + timeUntilAiring: number; + episode: number; + } | null; + }; +} + +export interface AniListResponse { + data: { + Page: { + pageInfo: { + total: number; + perPage: number; + currentPage: number; + lastPage: number; + hasNextPage: boolean; + }; + airingSchedules: AniListAiringSchedule[]; + }; + }; +} diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index 09d9fb82..c5924650 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -86,6 +86,13 @@ export interface StreamingContent { [key: string]: any; }; imdb_id?: string; + mal_id?: number; + external_ids?: { + mal_id?: number; + imdb_id?: string; + tmdb_id?: number; + tvdb_id?: number; + }; slug?: string; releaseInfo?: string; traktSource?: 'watchlist' | 'continue-watching' | 'watched'; diff --git a/src/services/introService.ts b/src/services/introService.ts index 9d96b43c..db6c949d 100644 --- a/src/services/introService.ts +++ b/src/services/introService.ts @@ -1,6 +1,7 @@ import axios from 'axios'; import { logger } from '../utils/logger'; import { tmdbService } from './tmdbService'; +import { ArmSyncService } from './mal/ArmSyncService'; /** * IntroDB API service for fetching TV show intro timestamps @@ -304,7 +305,8 @@ export async function getSkipTimes( season: number, episode: number, malId?: string, - kitsuId?: string + kitsuId?: string, + releaseDate?: string ): Promise { // 1. Try IntroDB (TV Shows) first if (imdbId) { @@ -316,7 +318,22 @@ export async function getSkipTimes( // 2. Try AniSkip (Anime) if we have MAL ID or Kitsu ID let finalMalId = malId; + let finalEpisode = episode; + // If we have IMDb ID and Release Date, try ArmSyncService to resolve exact MAL ID and Episode + if (!finalMalId && imdbId && releaseDate) { + try { + const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate); + if (armResult) { + finalMalId = armResult.malId.toString(); + finalEpisode = armResult.episode; + logger.log(`[IntroService] ArmSync resolved: MAL ${finalMalId} Ep ${finalEpisode}`); + } + } catch (e) { + logger.warn('[IntroService] ArmSync failed', e); + } + } + // If we have Kitsu ID but no MAL ID, try to resolve it if (!finalMalId && kitsuId) { logger.log(`[IntroService] Resolving MAL ID from Kitsu ID: ${kitsuId}`); @@ -337,8 +354,8 @@ export async function getSkipTimes( } if (finalMalId) { - logger.log(`[IntroService] Fetching AniSkip for MAL ID: ${finalMalId} Ep: ${episode}`); - const aniSkipIntervals = await fetchFromAniSkip(finalMalId, episode); + logger.log(`[IntroService] Fetching AniSkip for MAL ID: ${finalMalId} Ep: ${finalEpisode}`); + const aniSkipIntervals = await fetchFromAniSkip(finalMalId, finalEpisode); if (aniSkipIntervals.length > 0) { logger.log(`[IntroService] Found ${aniSkipIntervals.length} skip intervals from AniSkip`); return aniSkipIntervals; @@ -386,4 +403,4 @@ export const introService = { verifyApiKey }; -export default introService; +export default introService; \ No newline at end of file diff --git a/src/services/mal/ArmSyncService.ts b/src/services/mal/ArmSyncService.ts new file mode 100644 index 00000000..a6d283cf --- /dev/null +++ b/src/services/mal/ArmSyncService.ts @@ -0,0 +1,192 @@ +import axios from 'axios'; +import { logger } from '../../utils/logger'; + +interface ArmEntry { + anidb?: number; + anilist?: number; + 'anime-planet'?: string; + anisearch?: number; + imdb?: string; + kitsu?: number; + livechart?: number; + 'notify-moe'?: string; + themoviedb?: number; + thetvdb?: number; + myanimelist?: number; +} + +interface DateSyncResult { + malId: number; + episode: number; + title?: string; +} + +const JIKAN_BASE = 'https://api.jikan.moe/v4'; +const ARM_BASE = 'https://arm.haglund.dev/api/v2'; + +export const ArmSyncService = { + /** + * Resolves the correct MyAnimeList ID and Episode Number using ARM (for ID mapping) + * and Jikan (for Air Date matching). + * + * @param imdbId The IMDb ID of the show + * @param releaseDateStr The air date of the episode (YYYY-MM-DD) + * @param dayIndex The 0-based index of this episode among others released on the same day (optional) + * @returns {Promise} The resolved MAL ID and Episode number + */ + resolveByDate: async (imdbId: string, releaseDateStr: string, dayIndex?: number): Promise => { + try { + // Basic validation: ensure date is in YYYY-MM-DD format + if (!/^\d{4}-\d{2}-\d{2}/.test(releaseDateStr)) { + logger.warn(`[ArmSync] Invalid date format provided: ${releaseDateStr}`); + return null; + } + + logger.log(`[ArmSync] Resolving ${imdbId} for date ${releaseDateStr}...`); + + // 1. Fetch Candidates from ARM + const armRes = await axios.get(`${ARM_BASE}/imdb`, { + params: { id: imdbId } + }); + + const malIds = armRes.data + .map(entry => entry.myanimelist) + .filter((id): id is number => !!id); + + if (malIds.length === 0) { + logger.warn(`[ArmSync] No MAL IDs found in ARM for ${imdbId}`); + return null; + } + + logger.log(`[ArmSync] Found candidates: ${malIds.join(', ')}`); + + // 2. Validate Candidates + return await ArmSyncService.resolveFromMalCandidates(malIds, releaseDateStr, dayIndex); + } catch (e) { + logger.error('[ArmSync] Resolution failed:', e); + } + return null; + }, + + /** + * Resolves the correct MyAnimeList ID and Episode Number using ARM (for ID mapping) + * and Jikan (for Air Date matching) using a TMDB ID. + * + * @param tmdbId The TMDB ID of the show + * @param releaseDateStr The air date of the episode (YYYY-MM-DD) + * @param dayIndex The 0-based index of this episode among others released on the same day + * @returns {Promise} The resolved MAL ID and Episode number + */ + resolveByTmdb: async (tmdbId: number, releaseDateStr: string, dayIndex?: number): Promise => { + try { + if (!/^\d{4}-\d{2}-\d{2}/.test(releaseDateStr)) return null; + + logger.log(`[ArmSync] Resolving TMDB ${tmdbId} for date ${releaseDateStr}...`); + + // 1. Fetch Candidates from ARM using TMDB ID + const armRes = await axios.get(`${ARM_BASE}/tmdb`, { + params: { id: tmdbId } + }); + + const malIds = armRes.data + .map(entry => entry.myanimelist) + .filter((id): id is number => !!id); + + if (malIds.length === 0) return null; + + logger.log(`[ArmSync] Found candidates for TMDB ${tmdbId}: ${malIds.join(', ')}`); + + // 2. Validate Candidates + return await ArmSyncService.resolveFromMalCandidates(malIds, releaseDateStr, dayIndex); + } catch (e) { + logger.error('[ArmSync] TMDB resolution failed:', e); + } + return null; + }, + + /** + * Internal helper to find the correct MAL ID from a list of candidates based on date + */ + resolveFromMalCandidates: async (malIds: number[], releaseDateStr: string, dayIndex?: number): Promise => { + // Helper to delay (Jikan Rate Limit: 3 req/sec) + const delay = (ms: number) => new Promise(res => setTimeout(res, ms)); + + for (const malId of malIds) { + await delay(500); // Respect rate limits + try { + const detailsRes = await axios.get(`${JIKAN_BASE}/anime/${malId}`); + const anime = detailsRes.data.data; + + const startDateStr = anime.aired?.from ? new Date(anime.aired.from).toISOString().split('T')[0] : null; + const endDateStr = anime.aired?.to ? new Date(anime.aired.to).toISOString().split('T')[0] : null; + + // Date Matching Logic with Timezone Tolerance (2 days) + let isMatch = false; + if (startDateStr) { + const startLimit = new Date(startDateStr); + startLimit.setDate(startLimit.getDate() - 2); // Allow release date to be up to 2 days before official start + const startLimitStr = startLimit.toISOString().split('T')[0]; + + // Check if our episode date is >= Season Start Date (with tolerance) + if (releaseDateStr >= startLimitStr) { + // If season has ended, our episode must be <= Season End Date + if (!endDateStr || releaseDateStr <= endDateStr) { + isMatch = true; + } + } + } + + if (isMatch) { + logger.log(`[ArmSync] Match found! ID ${malId} covers ${releaseDateStr}`); + + // 3. Find Exact Episode (with tolerance) + await delay(500); + const epsRes = await axios.get(`${JIKAN_BASE}/anime/${malId}/episodes`); + const episodes = epsRes.data.data; + + const matchingEpisodes = episodes.filter((ep: any) => { + if (!ep.aired) return false; + try { + const epDate = new Date(ep.aired); + const targetDate = new Date(releaseDateStr); + + // Calculate difference in days + const diffTime = Math.abs(targetDate.getTime() - epDate.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + // Match if within 2 days (48 hours) + return diffDays <= 2; + } catch (e) { + return false; + } + }); + + if (matchingEpisodes.length > 0) { + // Sort matching episodes by their mal_id to ensure consistent ordering + matchingEpisodes.sort((a: any, b: any) => a.mal_id - b.mal_id); + + let matchEp = matchingEpisodes[0]; + + // If multiple episodes match the same day, use dayIndex to pick the correct one + if (matchingEpisodes.length > 1 && dayIndex !== undefined) { + // If the dayIndex is within bounds, pick it. Otherwise, pick the last one. + const idx = Math.min(dayIndex, matchingEpisodes.length - 1); + matchEp = matchingEpisodes[idx]; + logger.log(`[ArmSync] Disambiguated same-day release using dayIndex ${dayIndex} -> picked Ep #${matchEp.mal_id}`); + } + + logger.log(`[ArmSync] Episode resolved: #${matchEp.mal_id} (${matchEp.title})`); + return { + malId, + episode: matchEp.mal_id, + title: matchEp.title + }; + } + } + } catch (e) { + logger.warn(`[ArmSync] Failed to check candidate ${malId}:`, e); + } + } + return null; + } +}; diff --git a/src/services/mal/MalApi.ts b/src/services/mal/MalApi.ts new file mode 100644 index 00000000..bd1c267c --- /dev/null +++ b/src/services/mal/MalApi.ts @@ -0,0 +1,125 @@ +import axios from 'axios'; +import { mmkvStorage } from '../mmkvStorage'; +import { MalAuth } from './MalAuth'; +import { MalAnimeNode, MalListStatus, MalUserListResponse, MalSearchResult, MalUser } from '../../types/mal'; + +const CLIENT_ID = '4631b11b52008b79c9a05d63996fc5f8'; + +const api = axios.create({ + baseURL: 'https://api.myanimelist.net/v2', + headers: { + 'X-MAL-CLIENT-ID': CLIENT_ID, + }, +}); + +api.interceptors.request.use(async (config) => { + const token = MalAuth.getToken(); + if (token) { + if (MalAuth.isTokenExpired(token)) { + const refreshed = await MalAuth.refreshToken(); + if (refreshed) { + const newToken = MalAuth.getToken(); + if (newToken) { + config.headers.Authorization = `Bearer ${newToken.accessToken}`; + } + } + } else { + config.headers.Authorization = `Bearer ${token.accessToken}`; + } + } + return config; +}); + +export const MalApiService = { + getUserList: async (status?: MalListStatus, offset = 0, limit = 100): Promise => { + try { + const response = await api.get('/users/@me/animelist', { + params: { + status, + fields: 'list_status{score,num_episodes_watched,status},num_episodes,media_type,start_season', + limit, + offset, + sort: 'list_updated_at', + nsfw: mmkvStorage.getBoolean('mal_include_nsfw') ?? true + }, + }); + return response.data; + } catch (error) { + console.error('Failed to fetch MAL user list', error); + throw error; + } + }, + + searchAnime: async (query: string, limit = 5): Promise => { + try { + const response = await api.get('/anime', { + params: { q: query, limit }, + }); + return response.data; + } catch (error) { + console.error('Failed to search MAL anime', error); + throw error; + } + }, + + updateStatus: async ( + malId: number, + status: MalListStatus, + episode: number, + score?: number, + isRewatching?: boolean + ) => { + const data: any = { + status, + num_watched_episodes: episode, + is_rewatching: isRewatching || false + }; + if (score && score > 0) data.score = score; + + return api.put(`/anime/${malId}/my_list_status`, new URLSearchParams(data).toString(), { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }); + }, + + removeFromList: async (malId: number) => { + return api.delete(`/anime/${malId}/my_list_status`); + }, + + getAnimeDetails: async (malId: number) => { + try { + const response = await api.get(`/anime/${malId}`, { + params: { fields: 'id,title,main_picture,num_episodes,start_season,media_type' } + }); + return response.data; + } catch (error) { + console.error('Failed to get anime details', error); + throw error; + } + }, + + getUserInfo: async (): Promise => { + try { + const response = await api.get('/users/@me', { + params: { + fields: 'id,name,picture,gender,birthday,location,joined_at,anime_statistics,time_zone' + } + }); + return response.data; + } catch (error) { + console.error('Failed to get user info', error); + throw error; + } + }, + + getMyListStatus: async (malId: number): Promise<{ my_list_status?: any; num_episodes: number }> => { + try { + const response = await api.get(`/anime/${malId}`, { + params: { fields: 'my_list_status,num_episodes' } + }); + return response.data; + } catch (error) { + console.error('Failed to get my list status', error); + return { num_episodes: 0 }; + } + } +}; diff --git a/src/services/mal/MalAuth.ts b/src/services/mal/MalAuth.ts new file mode 100644 index 00000000..73e86537 --- /dev/null +++ b/src/services/mal/MalAuth.ts @@ -0,0 +1,260 @@ +import * as WebBrowser from 'expo-web-browser'; +import * as Crypto from 'expo-crypto'; +import { mmkvStorage } from '../mmkvStorage'; +import { MalToken } from '../../types/mal'; + +const CLIENT_ID = '4631b11b52008b79c9a05d63996fc5f8'; +const REDIRECT_URI = 'nuvio://auth'; + +const KEYS = { + ACCESS: 'mal_access_token', + REFRESH: 'mal_refresh_token', + EXPIRES: 'mal_expires_in', + CREATED: 'mal_created_at', +}; + +const discovery = { + authorizationEndpoint: 'https://myanimelist.net/v1/oauth2/authorize', + tokenEndpoint: 'https://myanimelist.net/v1/oauth2/token', +}; + +class MalAuthService { + private static instance: MalAuthService; + private token: MalToken | null = null; + private isAuthenticating = false; + + private constructor() {} + + static getInstance() { + if (!MalAuthService.instance) { + MalAuthService.instance = new MalAuthService(); + } + return MalAuthService.instance; + } + + getToken(): MalToken | null { + if (!this.token) { + const access = mmkvStorage.getString(KEYS.ACCESS); + if (access) { + this.token = { + accessToken: access, + refreshToken: mmkvStorage.getString(KEYS.REFRESH) || '', + expiresIn: mmkvStorage.getNumber(KEYS.EXPIRES) || 0, + createdAt: mmkvStorage.getNumber(KEYS.CREATED) || 0, + }; + } + } + return this.token; + } + + isAuthenticated(): boolean { + return this.getToken() !== null; + } + + saveToken(token: MalToken) { + this.token = token; + mmkvStorage.setString(KEYS.ACCESS, token.accessToken); + mmkvStorage.setString(KEYS.REFRESH, token.refreshToken); + mmkvStorage.setNumber(KEYS.EXPIRES, token.expiresIn); + mmkvStorage.setNumber(KEYS.CREATED, token.createdAt); + } + + clearToken() { + this.token = null; + mmkvStorage.delete(KEYS.ACCESS); + mmkvStorage.delete(KEYS.REFRESH); + mmkvStorage.delete(KEYS.EXPIRES); + mmkvStorage.delete(KEYS.CREATED); + } + + isTokenExpired(token: MalToken): boolean { + const now = Date.now(); + const expiryTime = token.createdAt + (token.expiresIn * 1000); + // Buffer of 5 minutes + return now > (expiryTime - 300000); + } + + private generateCodeVerifier(): string { + const length = 128; + const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + let result = ''; + const randomBytes = Crypto.getRandomBytes(length); + for (let i = 0; i < length; i++) { + result += charset[randomBytes[i] % charset.length]; + } + return result; + } + + private async exchangeToken(code: string, codeVerifier: string, uri: string) { + console.log(`[MalAuth] Attempting token exchange with redirect_uri: '${uri}'`); + const params = new URLSearchParams(); + params.append('client_id', CLIENT_ID); + params.append('grant_type', 'authorization_code'); + params.append('code', code); + params.append('redirect_uri', uri); + params.append('code_verifier', codeVerifier); + + const response = await fetch(discovery.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'Nuvio-Mobile-App', + }, + body: params.toString(), + }); + + // Handle non-JSON responses safely + const text = await response.text(); + const data = (() => { try { return JSON.parse(text); } catch { return { message: text }; } })(); + + if (!response.ok) { + const error: any = new Error(data.message || 'Token exchange failed'); + error.response = { data }; + // Attach specific error fields if available for easier checking + error.malError = data.error; + throw error; + } + return data; + } + + async login(): Promise { + if (this.isAuthenticating) return 'Authentication already in progress'; + this.isAuthenticating = true; + + try { + console.log('[MalAuth] Starting login with redirectUri:', REDIRECT_URI); + + const codeVerifier = this.generateCodeVerifier(); + const state = this.generateCodeVerifier().substring(0, 20); // Simple random state + + const params = new URLSearchParams({ + response_type: 'code', + client_id: CLIENT_ID, + state: state, + code_challenge: codeVerifier, + code_challenge_method: 'plain', + redirect_uri: REDIRECT_URI, + scope: 'user_read write_share', // space separated + }); + + const authUrl = `${discovery.authorizationEndpoint}?${params.toString()}`; + + const result = await WebBrowser.openAuthSessionAsync(authUrl, REDIRECT_URI, { + showInRecents: true, + }); + + console.log('[MalAuth] Auth prompt result:', result.type); + + if (result.type === 'success' && result.url) { + // Parse code from URL + const urlObj = new URL(result.url); + const code = urlObj.searchParams.get('code'); + const returnedState = urlObj.searchParams.get('state'); + + if (!code) { + return 'No authorization code received'; + } + + // Optional: verify state if you want strict security, though MAL state is optional + // if (returnedState !== state) console.warn('State mismatch'); + + console.log('[MalAuth] Success! Code received.'); + + try { + console.log('[MalAuth] Exchanging code for token...'); + const data = await this.exchangeToken(code, codeVerifier, REDIRECT_URI); + + if (data.access_token) { + console.log('[MalAuth] Token exchange successful'); + this.saveToken({ + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in, + createdAt: Date.now(), + }); + return true; + } + } catch (e: any) { + // Normalize error data + const errorData = e.response?.data || (e instanceof Error ? { message: e.message, error: (e as any).malError } : e); + console.error('[MalAuth] First Token Exchange Failed:', JSON.stringify(errorData)); + + // Retry with trailing slash if invalid_grant + if (errorData.error === 'invalid_grant' || (errorData.message && errorData.message.includes('redirection URI'))) { + const retryUri = REDIRECT_URI + '/'; + console.log(`[MalAuth] Retrying with trailing slash: '${retryUri}'`); + try { + const data = await this.exchangeToken(code, codeVerifier, retryUri); + if (data.access_token) { + console.log('[MalAuth] Retry Token exchange successful'); + this.saveToken({ + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in, + createdAt: Date.now(), + }); + return true; + } + } catch (retryError: any) { + const retryErrorData = retryError.response?.data || (retryError instanceof Error ? { message: retryError.message, error: (retryError as any).malError } : retryError); + console.error('[MalAuth] Retry Token Exchange Also Failed:', JSON.stringify(retryErrorData)); + return `MAL Error: ${retryErrorData.error || 'unknown'} - ${retryErrorData.message || 'No description'}`; + } + } + + if (errorData) { + return `MAL Error: ${errorData.error || 'unknown'} - ${errorData.message || errorData.error_description || 'No description'}`; + } + return `Network Error: ${e.message}`; + } + } else if (result.type === 'cancel' || result.type === 'dismiss') { + return 'Login cancelled'; + } + + return false; + } catch (e: any) { + console.error('[MalAuth] Login Exception', e); + return `Login Exception: ${e.message}`; + } finally { + this.isAuthenticating = false; + } + } + + async refreshToken(): Promise { + const token = this.getToken(); + if (!token || !token.refreshToken) return false; + + try { + const body = new URLSearchParams({ + client_id: CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: token.refreshToken, + }).toString(); + + const response = await fetch(discovery.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }); + + const data = await response.json(); + + if (data.access_token) { + this.saveToken({ + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in, + createdAt: Date.now(), + }); + return true; + } + } catch (e) { + console.error('MAL Token Refresh Error', e); + } + return false; + } +} + +export const MalAuth = MalAuthService.getInstance(); diff --git a/src/services/mal/MalSync.ts b/src/services/mal/MalSync.ts new file mode 100644 index 00000000..d89873fd --- /dev/null +++ b/src/services/mal/MalSync.ts @@ -0,0 +1,561 @@ +import { mmkvStorage } from '../mmkvStorage'; +import { MalApiService } from './MalApi'; +import { MalAuth } from './MalAuth'; +import { MalListStatus, MalAnimeNode } from '../../types/mal'; +import { catalogService } from '../catalogService'; +import { ArmSyncService } from './ArmSyncService'; +import { logger } from '../../utils/logger'; +import axios from 'axios'; + +const MAPPING_PREFIX = 'mal_map_'; +const getTitleCacheKey = (title: string, type: 'movie' | 'series', season = 1) => + `${MAPPING_PREFIX}${title.trim()}_${type}_${season}`; +const getLegacyTitleCacheKey = (title: string, type: 'movie' | 'series') => + `${MAPPING_PREFIX}${title.trim()}_${type}`; + +export const MalSync = { + /** + * Tries to find a MAL ID using IMDb ID via MAL-Sync API. + */ + getMalIdFromImdb: async (imdbId: string): Promise => { + if (!imdbId) return null; + + // 1. Check Cache + const cacheKey = `${MAPPING_PREFIX}imdb_${imdbId}`; + const cachedId = mmkvStorage.getNumber(cacheKey); + if (cachedId) return cachedId; + + // 2. Fetch from MAL-Sync API + try { + // Ensure ID format + const cleanId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + const response = await axios.get(`https://api.malsync.moe/mal/anime/imdb/${cleanId}`); + + if (response.data && response.data.id) { + const malId = response.data.id; + // Save to cache + mmkvStorage.setNumber(cacheKey, malId); + return malId; + } + } catch (e) { + // Ignore errors (404, etc.) + } + return null; + }, + + /** + * Tries to find a MAL ID for a given anime title or IMDb ID. + * Caches the result to avoid repeated API calls. + */ + getMalId: async (title: string, type: 'movie' | 'series' = 'series', year?: number, season?: number, imdbId?: string, episode: number = 1, releaseDate?: string, dayIndex?: number, tmdbId?: number): Promise => { + // Safety check: Never perform a MAL search for generic placeholders or empty strings. + // This prevents "cache poisoning" where a generic term matches a random anime. + const cleanTitle = title.trim(); + const normalizedTitle = cleanTitle.toLowerCase(); + const isGenericTitle = !normalizedTitle || normalizedTitle === 'anime' || normalizedTitle === 'movie'; + + const seasonNumber = season || 1; + const cacheKey = getTitleCacheKey(cleanTitle, type, seasonNumber); + const legacyCacheKey = getLegacyTitleCacheKey(cleanTitle, type); + const cachedId = mmkvStorage.getNumber(cacheKey) || mmkvStorage.getNumber(legacyCacheKey); + if (cachedId) { + // Backfill to season-aware key for future lookups. + if (!mmkvStorage.getNumber(cacheKey)) { + mmkvStorage.setNumber(cacheKey, cachedId); + } + return cachedId; + } + + if (isGenericTitle && !imdbId && !tmdbId) return null; + + // 1. Try TMDB-based Resolution (High Accuracy) + if (tmdbId && releaseDate) { + try { + const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate, dayIndex); + if (tmdbResult && tmdbResult.malId) { + console.log(`[MalSync] Found TMDB match: ${tmdbId} (${releaseDate}) -> MAL ${tmdbResult.malId}`); + return tmdbResult.malId; + } + } catch (e) { + console.warn('[MalSync] TMDB Sync failed:', e); + } + } + + // 2. Try ARM + Jikan Sync (IMDb fallback) + if (imdbId && type === 'series' && releaseDate) { + try { + const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate, dayIndex); + if (armResult && armResult.malId) { + console.log(`[MalSync] Found ARM match: ${imdbId} (${releaseDate}) -> MAL ${armResult.malId} Ep ${armResult.episode}`); + // Note: ArmSyncService returns the *absolute* episode number for MAL (e.g. 76) + // but our 'episode' arg is usually relative (e.g. 1). + // scrobbleEpisode uses the malId returned here, and potentially the episode number from ArmSync + // But getMalId just returns the ID. + // Ideally, scrobbleEpisode should call ArmSyncService directly to get both ID and correct Episode number. + // For now, we return the ID. + return armResult.malId; + } + } catch (e) { + console.warn('[MalSync] ARM Sync failed:', e); + } + } + + // 2. Try IMDb ID mapping when it is likely to be accurate, or when title is generic. + if (imdbId && (type === 'movie' || seasonNumber <= 1 || isGenericTitle)) { + const idFromImdb = await MalSync.getMalIdFromImdb(imdbId); + if (idFromImdb) return idFromImdb; + } + + // 3. Search MAL (Skip if generic title) + if (isGenericTitle) return null; + + try { + let searchQuery = cleanTitle; + // For Season 2+, explicitly search for that season + if (type === 'series' && season && season > 1) { + // Improve search query: "Attack on Titan Season 2" usually works better than just appending + searchQuery = `${cleanTitle} Season ${season}`; + } else if (type === 'series' && season === 0) { + // Improve Season 0 (Specials) lookup: "Attack on Titan Specials" or "Attack on Titan OVA" + // We search for both to find the most likely entry + searchQuery = `${cleanTitle} Specials`; + } + + const result = await MalApiService.searchAnime(searchQuery, 10); + if (result.data.length > 0) { + let candidates = result.data; + + // Filter by type first + if (type === 'movie') { + candidates = candidates.filter(r => r.node.media_type === 'movie'); + } else if (season === 0) { + // For Season 0, prioritize specials, ovas, and onas + candidates = candidates.filter(r => r.node.media_type === 'special' || r.node.media_type === 'ova' || r.node.media_type === 'ona'); + if (candidates.length === 0) { + // If no specific special types found, fallback to anything containing "Special" or "OVA" in title + candidates = result.data.filter(r => + r.node.title.toLowerCase().includes('special') || + r.node.title.toLowerCase().includes('ova') || + r.node.title.toLowerCase().includes('ona') + ); + } + } else { + candidates = candidates.filter(r => r.node.media_type === 'tv' || r.node.media_type === 'ona' || r.node.media_type === 'special' || r.node.media_type === 'ova'); + } + + if (candidates.length === 0) candidates = result.data; // Fallback to all if type filtering removes everything + + let bestMatch = candidates[0].node; + + // If year is provided, try to find an exact start year match + if (year) { + const yearMatch = candidates.find(r => r.node.start_season?.year === year); + if (yearMatch) { + bestMatch = yearMatch.node; + } else { + // Fuzzy year match (+/- 1 year) + const fuzzyMatch = candidates.find(r => r.node.start_season?.year && Math.abs(r.node.start_season.year - year) <= 1); + if (fuzzyMatch) bestMatch = fuzzyMatch.node; + } + } + + // Save to cache + mmkvStorage.setNumber(cacheKey, bestMatch.id); + mmkvStorage.setNumber(legacyCacheKey, bestMatch.id); + return bestMatch.id; + } + } catch (e) { + console.warn('MAL Search failed for', title); + } + return null; + }, + + /** + * Main function to track progress + */ + scrobbleEpisode: async ( + animeTitle: string, + episodeNumber: number, + totalEpisodes: number = 0, + type: 'movie' | 'series' = 'series', + season?: number, + imdbId?: string, + releaseDate?: string, + providedMalId?: number, // Optional: skip lookup if already known + dayIndex?: number, // 0-based index of episode in a same-day release batch + tmdbId?: number + ) => { + try { + // Requirement 9 & 10: Respect user settings and safety + const isEnabled = mmkvStorage.getBoolean('mal_enabled') ?? true; + const isAutoUpdate = mmkvStorage.getBoolean('mal_auto_update') ?? true; + + if (!isEnabled || !isAutoUpdate || !MalAuth.isAuthenticated()) { + return; + } + + let malId: number | null = providedMalId || null; + let finalEpisodeNumber = episodeNumber; + + // Strategy 1: TMDB-based Resolution (High Accuracy for Specials) + if (!malId && tmdbId && releaseDate) { + const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate, dayIndex); + if (tmdbResult) { + malId = tmdbResult.malId; + finalEpisodeNumber = tmdbResult.episode; + console.log(`[MalSync] TMDB Resolved: ${animeTitle} -> MAL ${malId} Ep ${finalEpisodeNumber}`); + } + } + + // Strategy 2: IMDb-based Resolution (Fallback) + if (!malId && imdbId && type === 'series' && releaseDate) { + const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate, dayIndex); + if (armResult) { + malId = armResult.malId; + finalEpisodeNumber = armResult.episode; + console.log(`[MalSync] ARM Resolved: ${animeTitle} -> MAL ${malId} Ep ${finalEpisodeNumber}`); + } + } + + // Fallback to standard lookup if ARM/TMDB failed and no ID provided + if (!malId) { + malId = await MalSync.getMalId(animeTitle, type, undefined, season, imdbId, episodeNumber, releaseDate, dayIndex, tmdbId); + } + + if (!malId) return; + + // Check current status on MAL to avoid overwriting completed/dropped shows + try { + const currentInfo = await MalApiService.getMyListStatus(malId); + const currentStatus = currentInfo.my_list_status?.status; + const currentEpisodesWatched = currentInfo.my_list_status?.num_episodes_watched || 0; + + // Requirement 4: Auto-Add Anime to MAL (Configurable) + if (!currentStatus) { + const autoAdd = mmkvStorage.getBoolean('mal_auto_add') ?? true; + if (!autoAdd) { + console.log(`[MalSync] Skipping scrobble for ${animeTitle}: Not in list and auto-add disabled`); + return; + } + } + + // If already completed or dropped, don't auto-update via scrobble + if (currentStatus === 'completed' || currentStatus === 'dropped') { + console.log(`[MalSync] Skipping update for ${animeTitle}: Status is ${currentStatus}`); + return; + } + + // If we are just starting (ep 1) or resuming (plan_to_watch/on_hold/null), set to watching + // Also ensure we don't downgrade episode count (though unlikely with scrobbling forward) + if (finalEpisodeNumber <= currentEpisodesWatched) { + console.log(`[MalSync] Skipping update for ${animeTitle}: Episode ${finalEpisodeNumber} <= Current ${currentEpisodesWatched}`); + return; + } + } catch (e) { + // If error (e.g. not found), proceed to add it + } + + let finalTotalEpisodes = totalEpisodes; + + // If totalEpisodes not provided, try to fetch it from MAL details + if (finalTotalEpisodes <= 0) { + try { + const details = await MalApiService.getAnimeDetails(malId); + if (details && details.num_episodes) { + finalTotalEpisodes = details.num_episodes; + } + } catch (e) { + // Fallback to 0 if details fetch fails + } + } + + // Determine Status + let status: MalListStatus = 'watching'; + if (finalTotalEpisodes > 0 && finalEpisodeNumber >= finalTotalEpisodes) { + status = 'completed'; + } + + await MalApiService.updateStatus(malId, status, finalEpisodeNumber); + console.log(`[MalSync] Synced ${animeTitle} Ep ${finalEpisodeNumber}/${finalTotalEpisodes || '?'} -> MAL ID ${malId} (${status})`); + } catch (e) { + console.error('[MalSync] Scrobble failed:', e); + } + }, + + /** + * Direct scrobble with known MAL ID and Episode + * Used when ArmSync has already resolved the exact details. + */ + scrobbleDirect: async (malId: number, episodeNumber: number) => { + try { + // Respect user settings and login status + const isEnabled = mmkvStorage.getBoolean('mal_enabled') ?? true; + const isAutoUpdate = mmkvStorage.getBoolean('mal_auto_update') ?? true; + if (!isEnabled || !isAutoUpdate || !MalAuth.isAuthenticated()) return; + + // Check current status + const currentInfo = await MalApiService.getMyListStatus(malId); + const currentStatus = currentInfo.my_list_status?.status; + + // Auto-Add check + if (!currentStatus) { + const autoAdd = mmkvStorage.getBoolean('mal_auto_add') ?? true; + if (!autoAdd) { + console.log(`[MalSync] Skipping direct scrobble: Not in list and auto-add disabled`); + return; + } + } + + // Safety checks (Completed/Dropped/Regression) + if (currentStatus === 'completed' || currentStatus === 'dropped') return; + if (currentInfo.my_list_status?.num_episodes_watched && episodeNumber <= currentInfo.my_list_status.num_episodes_watched) return; + + // Determine Status + let status: MalListStatus = 'watching'; + if (currentInfo.num_episodes > 0 && episodeNumber >= currentInfo.num_episodes) { + status = 'completed'; + } + + await MalApiService.updateStatus(malId, status, episodeNumber); + console.log(`[MalSync] Direct synced MAL ID ${malId} Ep ${episodeNumber} (${status})`); + } catch (e) { + console.error('[MalSync] Direct scrobble failed:', e); + } + }, + + /** + * Import MAL list items into local library + */ + syncMalToLibrary: async () => { + if (!MalAuth.isAuthenticated()) return false; + try { + let allItems: MalAnimeNode[] = []; + let offset = 0; + let hasMore = true; + + while (hasMore && offset < 1000) { // Limit to 1000 items for safety + const response = await MalApiService.getUserList(undefined, offset, 100); + if (response.data && response.data.length > 0) { + allItems = [...allItems, ...response.data]; + offset += response.data.length; + hasMore = !!response.paging.next; + } else { + hasMore = false; + } + } + + for (const item of allItems) { + const type = item.node.media_type === 'movie' ? 'movie' : 'series'; + const title = item.node.title.trim(); + mmkvStorage.setNumber(getTitleCacheKey(title, type, 1), item.node.id); + // Keep legacy key for backwards compatibility with old cache readers. + mmkvStorage.setNumber(getLegacyTitleCacheKey(title, type), item.node.id); + } + console.log(`[MalSync] Synced ${allItems.length} items to mapping cache.`); + + // If auto-sync to library is enabled, also add 'watching' items to Nuvio Library + if (mmkvStorage.getBoolean('mal_auto_sync_to_library') ?? false) { + await MalSync.syncMalWatchingToLibrary(); + } + + return true; + } catch (e) { + console.error('syncMalToLibrary failed', e); + return false; + } + }, + + /** + * Automatically adds MAL 'watching' items to the Nuvio Library + */ + syncMalWatchingToLibrary: async () => { + if (!MalAuth.isAuthenticated()) return; + try { + logger.log('[MalSync] Auto-syncing MAL watching items to library...'); + + const response = await MalApiService.getUserList('watching', 0, 50); + if (!response.data || response.data.length === 0) return; + + const currentLibrary = await catalogService.getLibraryItems(); + const libraryIds = new Set(currentLibrary.map(l => l.id)); + + // 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); + + 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); + } + }, + + /** + * Manually map an ID if auto-detection fails + */ + setMapping: (title: string, malId: number, type: 'movie' | 'series' = 'series', season: number = 1) => { + const cleanTitle = title.trim(); + mmkvStorage.setNumber(getTitleCacheKey(cleanTitle, type, season), malId); + // Keep legacy key for compatibility. + mmkvStorage.setNumber(getLegacyTitleCacheKey(cleanTitle, type), malId); + }, + + /** + * Get external IDs (IMDb, etc.) and season info from a MAL ID using MalSync API + */ + getIdsFromMalId: async (malId: number): Promise<{ imdbId: string | null; season: number }> => { + const cacheKey = `mal_ext_ids_v2_${malId}`; + const cached = mmkvStorage.getString(cacheKey); + if (cached) { + return JSON.parse(cached); + } + + try { + const response = await axios.get(`https://api.malsync.moe/mal/anime/${malId}`); + const data = response.data; + + let imdbId = null; + let season = data.season || 1; + + // Try to find IMDb ID in Sites + if (data.Sites && data.Sites.IMDB) { + const imdbKeys = Object.keys(data.Sites.IMDB); + if (imdbKeys.length > 0) { + imdbId = imdbKeys[0]; + } + } + + const result = { imdbId, season }; + mmkvStorage.setString(cacheKey, JSON.stringify(result)); + return result; + } catch (e) { + console.error('[MalSync] Failed to fetch external IDs:', e); + } + return { imdbId: null, season: 1 }; + }, + + /** + * Get weekly anime schedule from Jikan API (Adjusted to Local Timezone) + */ + getWeeklySchedule: async (): Promise => { + const cacheKey = 'mal_weekly_schedule_local_v2'; // Bump version for new format + const cached = mmkvStorage.getString(cacheKey); + const cacheTime = mmkvStorage.getNumber(`${cacheKey}_time`); + + // Cache for 24 hours + if (cached && cacheTime && (Date.now() - cacheTime < 24 * 60 * 60 * 1000)) { + return JSON.parse(cached); + } + + try { + // Jikan API rate limit mitigation + await new Promise(resolve => setTimeout(resolve, 500)); + + const response = await axios.get('https://api.jikan.moe/v4/schedules'); + const data = response.data.data; + + const daysOrder = ['Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays', 'Sundays']; + const dayMap: Record = { 'Mondays': 0, 'Tuesdays': 1, 'Wednesdays': 2, 'Thursdays': 3, 'Fridays': 4, 'Saturdays': 5, 'Sundays': 6 }; + const daysReverse = ['Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays', 'Sundays']; + + const grouped: Record = {}; + + // Calculate time difference in minutes: Local - JST (UTC+9) + // getTimezoneOffset() returns minutes BEHIND UTC (positive for US, negative for Asia) + // We want Local - UTC+9. + // Local = UTC - offset. + // Diff = (UTC - localOffset) - (UTC + 540) = -localOffset - 540. + const jstOffset = 540; // UTC+9 in minutes + const localOffset = new Date().getTimezoneOffset(); // e.g. 300 for EST (UTC-5) + const offsetMinutes = -localOffset - jstOffset; // e.g. -300 - 540 = -840 minutes (-14h) + + data.forEach((anime: any) => { + let day = anime.broadcast?.day; // "Mondays" + let time = anime.broadcast?.time; // "23:00" + let originalDay = day; + + // Adjust to local time + if (day && time && dayMap[day] !== undefined) { + const [hours, mins] = time.split(':').map(Number); + let totalMinutes = hours * 60 + mins + offsetMinutes; + + let dayShift = 0; + // Handle day rollovers + if (totalMinutes < 0) { + totalMinutes += 24 * 60; + dayShift = -1; + } else if (totalMinutes >= 24 * 60) { + totalMinutes -= 24 * 60; + dayShift = 1; + } + + const newHour = Math.floor(totalMinutes / 60); + const newMin = totalMinutes % 60; + time = `${String(newHour).padStart(2,'0')}:${String(newMin).padStart(2,'0')}`; + + let dayIndex = dayMap[day] + dayShift; + if (dayIndex < 0) dayIndex = 6; + if (dayIndex > 6) dayIndex = 0; + day = daysReverse[dayIndex]; + } else { + day = 'Other'; // No specific time/day + } + + if (!grouped[day]) grouped[day] = []; + + grouped[day].push({ + id: `mal:${anime.mal_id}`, + seriesId: `mal:${anime.mal_id}`, + title: anime.title, + seriesName: anime.title_english || anime.title, + poster: anime.images?.jpg?.large_image_url || anime.images?.jpg?.image_url, + releaseDate: null, + season: 1, + episode: 1, + overview: anime.synopsis, + vote_average: anime.score, + day: day, + time: time, + genres: anime.genres?.map((g: any) => g.name) || [], + originalDay: originalDay // Keep for debug if needed + }); + }); + + // Sort by day (starting Monday or Today?) -> Standard is Monday start for anime + // Sort items by time within day + const result = [...daysOrder, 'Other'] + .filter(day => grouped[day] && grouped[day].length > 0) + .map(day => ({ + title: day, + data: grouped[day].sort((a, b) => (a.time || '99:99').localeCompare(b.time || '99:99')) + })); + + mmkvStorage.setString(cacheKey, JSON.stringify(result)); + mmkvStorage.setNumber(`${cacheKey}_time`, Date.now()); + + return result; + } catch (e) { + console.error('[MalSync] Failed to fetch schedule:', e); + return []; + } + } +}; diff --git a/src/services/mal/index.ts b/src/services/mal/index.ts new file mode 100644 index 00000000..68c60005 --- /dev/null +++ b/src/services/mal/index.ts @@ -0,0 +1,4 @@ +export * from '../../types/mal'; +export * from './MalAuth'; +export * from './MalApi'; +export * from './MalSync'; diff --git a/src/services/pluginService.ts b/src/services/pluginService.ts index a84437f4..cd5cb8c8 100644 --- a/src/services/pluginService.ts +++ b/src/services/pluginService.ts @@ -1270,29 +1270,75 @@ class LocalScraperService { } - private async executePlugin(code: string, params: any, consoleOverride?: any): Promise { + // Execute scraper code with full access to app environment (non-sandboxed) + public async testPlugin(code: string, params: any, options?: { onLog?: (line: string) => void }): Promise<{ streams: Stream[] }> { try { + // Create a specialized logger for testing that also calls the onLog callback + const testLogger = { + ...logger, + log: (...args: any[]) => { + logger.log('[PluginTest]', ...args); + options?.onLog?.(`[LOG] ${args.join(' ')}`); + }, + info: (...args: any[]) => { + logger.info('[PluginTest]', ...args); + options?.onLog?.(`[INFO] ${args.join(' ')}`); + }, + warn: (...args: any[]) => { + logger.warn('[PluginTest]', ...args); + options?.onLog?.(`[WARN] ${args.join(' ')}`); + }, + error: (...args: any[]) => { + logger.error('[PluginTest]', ...args); + options?.onLog?.(`[ERROR] ${args.join(' ')}`); + }, + debug: (...args: any[]) => { + logger.debug('[PluginTest]', ...args); + options?.onLog?.(`[DEBUG] ${args.join(' ')}`); + } + }; + + const result = await this.executePluginInternal(code, params, testLogger); + + // Use a dummy scraper info for the conversion + const dummyScraper: ScraperInfo = { + id: 'test-plugin', + name: 'Test Plugin', + description: 'Testing environment', + version: '1.0.0', + filename: 'test.js', + supportedTypes: ['movie', 'tv'], + enabled: true + }; + + const streams = this.convertToStreams(result, dummyScraper); + return { streams }; + } catch (error: any) { + logger.error('[LocalScraperService] testPlugin failed:', error); + options?.onLog?.(`[FATAL] ${error.message}`); + throw error; + } + } + + // Execute scraper code with full access to app environment (non-sandboxed) + private async executePlugin(code: string, params: any): Promise { + return this.executePluginInternal(code, params, logger); + } + + private async executePluginInternal(code: string, params: any, customLogger: any): Promise { + try { + // Get URL validation setting from storage + const settingsData = await mmkvStorage.getItem('app_settings'); + const settings = settingsData ? JSON.parse(settingsData) : {}; + const urlValidationEnabled = settings.enableScraperUrlValidation ?? true; + + // Load per-scraper settings for this run const allScraperSettingsRaw = await mmkvStorage.getItem(this.SCRAPER_SETTINGS_KEY); const allScraperSettings = allScraperSettingsRaw ? JSON.parse(allScraperSettingsRaw) : {}; - let perScraperSettings = (params && params.scraperId && allScraperSettings[params.scraperId]) + const perScraperSettings = (params && params.scraperId && allScraperSettings[params.scraperId]) ? allScraperSettings[params.scraperId] : (params?.settings || {}); - if (params?.scraperId?.toLowerCase().includes('showbox')) { - const token = perScraperSettings.uiToken || perScraperSettings.cookie || perScraperSettings.token; - if (token) { - perScraperSettings = { - ...perScraperSettings, - uiToken: token, - cookie: token, - token: token - }; - if (params) { - params.settings = perScraperSettings; - } - } - } - // Module exports for CommonJS compatibility const moduleExports: any = {}; const moduleObj = { exports: moduleExports }; @@ -1448,8 +1494,9 @@ class LocalScraperService { // Execution timeout (1 minute) const PLUGIN_TIMEOUT_MS = 60000; + const functionName = params.functionName || 'getStreams'; - const executionPromise = new Promise((resolve, reject) => { + const executionPromise = new Promise((resolve, reject) => { try { // Create function with full global access // We pass specific utilities but the plugin has access to everything @@ -1462,7 +1509,6 @@ class LocalScraperService { 'CryptoJS', 'cheerio', 'logger', - 'console', 'params', 'PRIMARY_KEY', 'TMDB_API_KEY', @@ -1477,6 +1523,9 @@ class LocalScraperService { globalScope.TMDB_API_KEY = TMDB_API_KEY; globalScope.SCRAPER_SETTINGS = SCRAPER_SETTINGS; globalScope.SCRAPER_ID = SCRAPER_ID; + if (typeof URL_VALIDATION_ENABLED !== 'undefined') { + globalScope.URL_VALIDATION_ENABLED = URL_VALIDATION_ENABLED; + } } else { logger.error('[Plugin Sandbox] Could not find global scope to inject settings'); } @@ -1484,15 +1533,16 @@ class LocalScraperService { // Plugin code ${code} - // Find and call getStreams function - if (typeof getStreams === 'function') { - return getStreams(params.tmdbId, params.mediaType, params.season, params.episode); - } else if (module.exports && typeof module.exports.getStreams === 'function') { - return module.exports.getStreams(params.tmdbId, params.mediaType, params.season, params.episode); - } else if (typeof global !== 'undefined' && typeof global.getStreams === 'function') { - return global.getStreams(params.tmdbId, params.mediaType, params.season, params.episode); + // Find and call target function (${functionName}) + if (typeof ${functionName} === 'function') { + return ${functionName}(params.tmdbId, params.mediaType, params.season, params.episode); + } else if (module.exports && typeof module.exports.${functionName} === 'function') { + return module.exports.${functionName}(params.tmdbId, params.mediaType, params.season, params.episode); + } else if (typeof global !== 'undefined' && typeof global.${functionName} === 'function') { + return global.${functionName}(params.tmdbId, params.mediaType, params.season, params.episode); } else { - throw new Error('No getStreams function found in plugin'); + // Return null if function not found (allow optional implementation) + return null; } ` ); @@ -1506,8 +1556,7 @@ class LocalScraperService { polyfilledFetch, // Use polyfilled fetch for redirect: manual support CryptoJS, cheerio, - logger, - consoleOverride || console, // Expose console (or override) to plugins for debugging + customLogger, params, MOVIEBOX_PRIMARY_KEY, MOVIEBOX_TMDB_API_KEY, @@ -1519,7 +1568,7 @@ class LocalScraperService { if (result && typeof result.then === 'function') { result.then(resolve).catch(reject); } else { - resolve(result || []); + resolve(result); } } catch (error) { reject(error); @@ -1535,11 +1584,80 @@ class LocalScraperService { ]); } catch (error) { - logger.error('[LocalScraperService] Plugin execution failed:', error); + customLogger.error('[LocalScraperService] Plugin execution failed:', error); throw error; } } + // Get subtitles from plugins + async getSubtitles(type: string, tmdbId: string, season?: number, episode?: number): Promise { + await this.ensureInitialized(); + + // Check if local scrapers are enabled + const userSettings = await this.getUserScraperSettings(); + if (!userSettings.enableLocalScrapers) { + return []; + } + + // Get available scrapers from manifest (respects manifestEnabled) + const availableScrapers = await this.getAvailableScrapers(); + const enabledScrapers = availableScrapers + .filter(scraper => + scraper.enabled && + scraper.manifestEnabled !== false + ); + + if (enabledScrapers.length === 0) { + return []; + } + + logger.log(`[LocalScraperService] Fetching subtitles from ${enabledScrapers.length} plugins for ${type}:${tmdbId}`); + + const results = await Promise.allSettled( + enabledScrapers.map(async (scraper) => { + try { + const code = this.scraperCode.get(scraper.id); + if (!code) return []; + + // Load per-scraper settings + const scraperSettings = await this.getScraperSettings(scraper.id); + + const subtitleResults = await this.executePlugin(code, { + tmdbId, + mediaType: type === 'series' ? 'tv' : 'movie', + season, + episode, + scraperId: scraper.id, + settings: scraperSettings, + functionName: 'getSubtitles' + }); + + if (Array.isArray(subtitleResults)) { + return subtitleResults.map(sub => ({ + ...sub, + addon: scraper.id, + addonName: scraper.name, + source: scraper.name + })); + } + return []; + } catch (e) { + // Ignore errors for individual plugins + return []; + } + }) + ); + + const allSubtitles: any[] = []; + results.forEach(result => { + if (result.status === 'fulfilled' && Array.isArray(result.value)) { + allSubtitles.push(...result.value); + } + }); + + return allSubtitles; + } + // Convert scraper results to Nuvio Stream format private convertToStreams(results: LocalScraperResult[], scraper: ScraperInfo): Stream[] { if (!Array.isArray(results)) { @@ -1722,73 +1840,6 @@ class LocalScraperService { } } - // Test a plugin independently with log capturing. - // If onLog is provided, each formatted log line is emitted as it happens. - async testPlugin( - code: string, - params: { tmdbId: string; mediaType: string; season?: number; episode?: number }, - options?: { onLog?: (line: string) => void } - ): Promise<{ streams: Stream[]; logs: string[] }> { - const logs: string[] = []; - const emit = (line: string) => { - logs.push(line); - options?.onLog?.(line); - }; - - // Create a console proxy to capture logs - const consoleProxy = { - log: (...args: any[]) => { - const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' '); - emit(`[LOG] ${msg}`); - console.log('[PluginTest]', msg); - }, - error: (...args: any[]) => { - const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' '); - emit(`[ERROR] ${msg}`); - console.error('[PluginTest]', msg); - }, - warn: (...args: any[]) => { - const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' '); - emit(`[WARN] ${msg}`); - console.warn('[PluginTest]', msg); - }, - info: (...args: any[]) => { - const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' '); - emit(`[INFO] ${msg}`); - console.info('[PluginTest]', msg); - }, - debug: (...args: any[]) => { - const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' '); - emit(`[DEBUG] ${msg}`); - console.debug('[PluginTest]', msg); - } - }; - - try { - const results = await this.executePlugin(code, params, consoleProxy); - - // Convert results using a dummy scraper info since we don't have one for ad-hoc tests - const dummyScraperInfo: ScraperInfo = { - id: 'test-plugin', - name: 'Test Plugin', - version: '1.0.0', - description: 'Test', - filename: 'test.js', - supportedTypes: ['movie', 'tv'], - enabled: true - }; - - const streams = this.convertToStreams(results, dummyScraperInfo); - return { streams, logs }; - } catch (error: any) { - emit(`[FATAL ERROR] ${error.message || String(error)}`); - if (error.stack) { - emit(`[STACK] ${error.stack}`); - } - return { streams: [], logs }; - } - } - } export const localScraperService = LocalScraperService.getInstance(); diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index 9859886a..5a211431 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -5,6 +5,7 @@ import EventEmitter from 'eventemitter3'; import { localScraperService } from './pluginService'; import { DEFAULT_SETTINGS, AppSettings } from '../hooks/useSettings'; import { TMDBService } from './tmdbService'; +import { MalSync } from './mal/MalSync'; import { safeAxiosConfig, createSafeAxiosConfig } from '../utils/axiosConfig'; // Create an event emitter for addon changes @@ -1257,6 +1258,9 @@ class StremioService { async getStreams(type: string, id: string, callback?: StreamCallback): Promise { await this.ensureInitialized(); + let activeId = id; + let resolvedTmdbId: string | null = null; + const addons = this.getInstalledAddons(); // Some addons use non-standard meta types (e.g. "anime") but expect streams under the "series" endpoint. @@ -1320,7 +1324,7 @@ class StremioService { const scraperType = type === 'series' ? 'tv' : type; // Parse the Stremio ID to extract ID and season/episode info - let tmdbId: string | null = null; + let tmdbId: string | null = resolvedTmdbId; let season: number | undefined = undefined; let episode: number | undefined = undefined; let idType: 'imdb' | 'kitsu' | 'tmdb' = 'imdb'; @@ -1566,7 +1570,7 @@ class StremioService { } const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url); - const encodedId = encodeURIComponent(id); + const encodedId = encodeURIComponent(activeId); const url = queryParams ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}` : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`; logger.log( @@ -2058,8 +2062,6 @@ class StremioService { // Check if any installed addons can provide streams (including embedded streams in metadata) async hasStreamProviders(type?: string): Promise { await this.ensureInitialized(); - // App-level content type "tv" maps to Stremio "series" - const normalizedType = type === 'tv' ? 'series' : type; const addons = Array.from(this.installedAddons.values()); for (const addon of addons) { @@ -2073,12 +2075,12 @@ class StremioService { if (hasStreamResource) { // If type specified, also check if addon supports this type - if (normalizedType) { - const supportsType = addon.types?.includes(normalizedType) || + if (type) { + const supportsType = addon.types?.includes(type) || addon.resources.some(resource => typeof resource === 'object' && (resource as any).name === 'stream' && - (resource as any).types?.includes(normalizedType) + (resource as any).types?.includes(type) ); if (supportsType) return true; } else { @@ -2088,14 +2090,14 @@ class StremioService { // Also check for addons with meta resource that support the type // These addons might provide embedded streams within metadata - if (normalizedType) { + if (type) { const hasMetaResource = addon.resources.some(resource => typeof resource === 'string' ? resource === 'meta' : (resource as any).name === 'meta' ); - if (hasMetaResource && addon.types?.includes(normalizedType)) { + if (hasMetaResource && addon.types?.includes(type)) { // This addon provides meta for the type - might have embedded streams return true; } diff --git a/src/services/watchedService.ts b/src/services/watchedService.ts index 5b609ff2..e83e0bf5 100644 --- a/src/services/watchedService.ts +++ b/src/services/watchedService.ts @@ -3,6 +3,9 @@ import { SimklService } from './simklService'; import { storageService } from './storageService'; import { mmkvStorage } from './mmkvStorage'; import { logger } from '../utils/logger'; +import { MalSync } from './mal/MalSync'; +import { MalAuth } from './mal/MalAuth'; +import { ArmSyncService } from './mal/ArmSyncService'; export interface LocalWatchedItem { content_id: string; @@ -15,10 +18,10 @@ export interface LocalWatchedItem { /** * WatchedService - Manages "watched" status for movies, episodes, and seasons. - * Handles both local storage and Trakt sync transparently. - * - * When Trakt is authenticated, it syncs to Trakt. - * When not authenticated, it stores locally. + * Handles both local storage and Trakt/Simkl/MAL sync transparently. + * + * When a service is authenticated, it syncs to that service. + * Always stores locally for offline access and fallback. */ class WatchedService { private static instance: WatchedService; @@ -198,21 +201,39 @@ class WatchedService { */ public async markMovieAsWatched( imdbId: string, - watchedAt: Date = new Date() + watchedAt: Date = new Date(), + malId?: number, + tmdbId?: number, + title?: string ): Promise<{ success: boolean; syncedToTrakt: boolean }> { try { logger.log(`[WatchedService] Marking movie as watched: ${imdbId}`); - // Check if Trakt is authenticated const isTraktAuth = await this.traktService.isAuthenticated(); let syncedToTrakt = false; + // Sync to Trakt if (isTraktAuth) { - // Sync to Trakt syncedToTrakt = await this.traktService.addToWatchedMovies(imdbId, watchedAt); logger.log(`[WatchedService] Trakt sync result for movie: ${syncedToTrakt}`); } + // Sync to MAL + if (MalAuth.isAuthenticated()) { + MalSync.scrobbleEpisode( + title || 'Movie', // Use real title or generic fallback + 1, + 1, + 'movie', + undefined, + imdbId, + undefined, + malId, + undefined, + tmdbId + ).catch(err => logger.error('[WatchedService] MAL movie sync failed:', err)); + } + // Sync to Simkl const isSimklAuth = await this.simklService.isAuthenticated(); if (isSimklAuth) { @@ -253,17 +274,21 @@ class WatchedService { showId: string, season: number, episode: number, - watchedAt: Date = new Date() + watchedAt: Date = new Date(), + releaseDate?: string, // Optional release date for precise matching + showTitle?: string, + malId?: number, + dayIndex?: number, + tmdbId?: number ): Promise<{ success: boolean; syncedToTrakt: boolean }> { try { logger.log(`[WatchedService] Marking episode as watched: ${showImdbId} S${season}E${episode}`); - // Check if Trakt is authenticated const isTraktAuth = await this.traktService.isAuthenticated(); let syncedToTrakt = false; + // Sync to Trakt if (isTraktAuth) { - // Sync to Trakt syncedToTrakt = await this.traktService.addToWatchedEpisodes( showImdbId, season, @@ -273,6 +298,58 @@ class WatchedService { logger.log(`[WatchedService] Trakt sync result for episode: ${syncedToTrakt}`); } + // Sync to MAL + if (MalAuth.isAuthenticated() && (showImdbId || malId || tmdbId)) { + // Strategy 0: Direct Match (if malId is provided) + let synced = false; + if (malId) { + await MalSync.scrobbleDirect(malId, episode); + synced = true; + } + + // Strategy 1: TMDB-based Resolution (High Accuracy for Specials) + if (!synced && releaseDate && tmdbId) { + try { + const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate, dayIndex); + if (tmdbResult) { + await MalSync.scrobbleDirect(tmdbResult.malId, tmdbResult.episode); + synced = true; + } + } catch (e) { + logger.warn('[WatchedService] TMDB Sync failed, falling back to IMDb:', e); + } + } + + // Strategy 2: IMDb-based Resolution (Fallback) + if (!synced && releaseDate && showImdbId) { + try { + const armResult = await ArmSyncService.resolveByDate(showImdbId, releaseDate, dayIndex); + if (armResult) { + await MalSync.scrobbleDirect(armResult.malId, armResult.episode); + synced = true; + } + } catch (e) { + logger.warn('[WatchedService] ARM Sync failed, falling back to offline map:', e); + } + } + + // Strategy 3: Offline Mapping / Search Fallback + if (!synced) { + MalSync.scrobbleEpisode( + showTitle || showImdbId || 'Anime', + episode, + 0, + 'series', + season, + showImdbId, + releaseDate, + malId, + dayIndex, + tmdbId + ).catch(err => logger.error('[WatchedService] MAL sync failed:', err)); + } + } + // Sync to Simkl const isSimklAuth = await this.simklService.isAuthenticated(); if (isSimklAuth) { diff --git a/src/types/calendar.ts b/src/types/calendar.ts new file mode 100644 index 00000000..0065b2e2 --- /dev/null +++ b/src/types/calendar.ts @@ -0,0 +1,23 @@ +export interface CalendarEpisode { + id: string; + seriesId: string; + title: string; + seriesName: string; + poster: string; + releaseDate: string | null; + season: number; + episode: number; + overview: string; + vote_average: number; + still_path: string | null; + season_poster_path: string | null; + // MAL specific + day?: string; + time?: string; + genres?: string[]; +} + +export interface CalendarSection { + title: string; + data: CalendarEpisode[]; +} \ No newline at end of file diff --git a/src/types/mal.ts b/src/types/mal.ts new file mode 100644 index 00000000..621916d4 --- /dev/null +++ b/src/types/mal.ts @@ -0,0 +1,80 @@ +export interface MalToken { + accessToken: string; + refreshToken: string; + expiresIn: number; // Seconds + createdAt: number; // Timestamp +} + +export interface MalUser { + id: number; + name: string; + picture?: string; + gender?: string; + birthday?: string; + location?: string; + joined_at?: string; + time_zone?: string; + anime_statistics?: { + num_items_watching: number; + num_items_completed: number; + num_items_on_hold: number; + num_items_dropped: number; + num_items_plan_to_watch: number; + num_items: number; + num_days_watched: number; + num_days_watching: number; + num_days_completed: number; + num_days_on_hold: number; + num_days_dropped: number; + num_days: number; + num_episodes: number; + num_times_rewatched: number; + mean_score: number; + }; +} + +export interface MalAnime { + id: number; + title: string; + main_picture?: { + medium: string; + large: string; + }; + num_episodes: number; + media_type?: 'tv' | 'movie' | 'ova' | 'special' | 'ona' | 'music'; + start_season?: { + year: number; + season: string; + }; +} + +export type MalListStatus = 'watching' | 'completed' | 'on_hold' | 'dropped' | 'plan_to_watch'; + +export interface MalMyListStatus { + status: MalListStatus; + score: number; + num_episodes_watched: number; + is_rewatching: boolean; + updated_at: string; +} + +export interface MalAnimeNode { + node: MalAnime; + list_status: MalMyListStatus; +} + +export interface MalUserListResponse { + data: MalAnimeNode[]; + paging: { + next?: string; + previous?: string; + }; +} + +export interface MalSearchResult { + data: MalAnimeNode[]; + paging: { + next?: string; + previous?: string; + }; +}