mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-22 18:47:44 +00:00
commit
1367972681
42 changed files with 4085 additions and 880 deletions
|
|
@ -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<String, String>?) {
|
||||
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", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
assets/rating-icons/mal-icon.png
Normal file
BIN
assets/rating-icons/mal-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -110,21 +110,23 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
|||
}
|
||||
|
||||
fun setPlayer(player: ExoPlayer?) {
|
||||
val currentPlayer = playerView.player
|
||||
|
||||
if (currentPlayer != null) {
|
||||
currentPlayer.removeListener(playerListener)
|
||||
}
|
||||
|
||||
playerView.player = player
|
||||
player?.addListener(playerListener)
|
||||
}
|
||||
|
||||
if (player != null) {
|
||||
player.addListener(playerListener)
|
||||
|
||||
// Apply pending resize mode if we have one
|
||||
pendingResizeMode?.let { resizeMode ->
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
fun setResizeMode(@ResizeMode.Mode mode: Int) {
|
||||
val resizeMode = when (mode) {
|
||||
ResizeMode.RESIZE_MODE_FIT -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
ResizeMode.RESIZE_MODE_FIXED_WIDTH -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
ResizeMode.RESIZE_MODE_FIXED_HEIGHT -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
|
||||
ResizeMode.RESIZE_MODE_FILL -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FILL
|
||||
ResizeMode.RESIZE_MODE_ZOOM -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
else -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
if (playerView.width > 0 && playerView.height > 0) {
|
||||
playerView.resizeMode = resizeMode
|
||||
} else {
|
||||
pendingResizeMode = resizeMode
|
||||
}
|
||||
|
||||
// Re-assert subtitle rendering mode for the current style.
|
||||
|
|
@ -134,27 +136,34 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
|||
|
||||
fun getPlayerView(): PlayerView = playerView
|
||||
|
||||
fun setResizeMode(@ResizeMode.Mode resizeMode: Int) {
|
||||
val targetResizeMode = when (resizeMode) {
|
||||
ResizeMode.RESIZE_MODE_FILL -> AspectRatioFrameLayout.RESIZE_MODE_FILL
|
||||
ResizeMode.RESIZE_MODE_CENTER_CROP -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
ResizeMode.RESIZE_MODE_FIT -> AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
ResizeMode.RESIZE_MODE_FIXED_WIDTH -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
ResizeMode.RESIZE_MODE_FIXED_HEIGHT -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
|
||||
else -> AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
fun setShowSubtitleButton(show: Boolean) {
|
||||
playerView.setShowSubtitleButton(show)
|
||||
}
|
||||
|
||||
// Apply the resize mode to PlayerView immediately
|
||||
playerView.resizeMode = targetResizeMode
|
||||
fun setUseController(useController: Boolean) {
|
||||
playerView.useController = useController
|
||||
}
|
||||
|
||||
// Store it for reapplication if needed
|
||||
pendingResizeMode = targetResizeMode
|
||||
fun setControllerHideOnTouch(hideOnTouch: Boolean) {
|
||||
playerView.setControllerHideOnTouch(hideOnTouch)
|
||||
}
|
||||
|
||||
// Force PlayerView to recalculate its layout
|
||||
playerView.requestLayout()
|
||||
fun setControllerAutoShow(autoShow: Boolean) {
|
||||
playerView.setControllerAutoShow(autoShow)
|
||||
}
|
||||
|
||||
// Also request layout on the parent to ensure proper sizing
|
||||
requestLayout()
|
||||
fun setControllerShowTimeoutMs(timeoutMs: Int) {
|
||||
playerView.controllerShowTimeoutMs = timeoutMs
|
||||
}
|
||||
|
||||
fun isControllerVisible(): Boolean = playerView.isControllerFullyVisible
|
||||
|
||||
fun hideController() {
|
||||
playerView.hideController()
|
||||
}
|
||||
|
||||
fun showController() {
|
||||
playerView.showController()
|
||||
}
|
||||
|
||||
fun setSubtitleStyle(style: SubtitleStyle) {
|
||||
|
|
@ -281,89 +290,16 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
|||
playerView.setShutterBackgroundColor(color)
|
||||
}
|
||||
|
||||
fun updateSurfaceView(viewType: Int) {
|
||||
// TODO: Implement proper surface type switching if needed
|
||||
fun setShowLiveBadge(show: Boolean) {
|
||||
liveBadge.visibility = if (show) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
val isPlaying: Boolean
|
||||
get() = playerView.player?.isPlaying ?: false
|
||||
|
||||
fun invalidateAspectRatio() {
|
||||
// PlayerView handles aspect ratio automatically through its internal AspectRatioFrameLayout
|
||||
playerView.requestLayout()
|
||||
|
||||
// Reapply the current resize mode to ensure it's properly set
|
||||
pendingResizeMode?.let { resizeMode ->
|
||||
playerView.resizeMode = resizeMode
|
||||
playerView.post {
|
||||
playerView.requestLayout()
|
||||
}
|
||||
}
|
||||
|
||||
fun setUseController(useController: Boolean) {
|
||||
playerView.useController = useController
|
||||
if (useController) {
|
||||
// Ensure proper touch handling when controls are enabled
|
||||
playerView.controllerAutoShow = true
|
||||
playerView.controllerHideOnTouch = true
|
||||
// Show controls immediately when enabled
|
||||
playerView.showController()
|
||||
}
|
||||
}
|
||||
|
||||
fun showController() {
|
||||
playerView.showController()
|
||||
}
|
||||
|
||||
fun hideController() {
|
||||
playerView.hideController()
|
||||
}
|
||||
|
||||
fun setControllerShowTimeoutMs(showTimeoutMs: Int) {
|
||||
playerView.controllerShowTimeoutMs = showTimeoutMs
|
||||
}
|
||||
|
||||
fun setControllerAutoShow(autoShow: Boolean) {
|
||||
playerView.controllerAutoShow = autoShow
|
||||
}
|
||||
|
||||
fun setControllerHideOnTouch(hideOnTouch: Boolean) {
|
||||
playerView.controllerHideOnTouch = hideOnTouch
|
||||
}
|
||||
|
||||
fun setFullscreenButtonClickListener(listener: PlayerView.FullscreenButtonClickListener?) {
|
||||
playerView.setFullscreenButtonClickListener(listener)
|
||||
}
|
||||
|
||||
fun setShowSubtitleButton(show: Boolean) {
|
||||
playerView.setShowSubtitleButton(show)
|
||||
}
|
||||
|
||||
fun isControllerVisible(): Boolean = playerView.isControllerFullyVisible
|
||||
|
||||
fun setControllerVisibilityListener(listener: PlayerView.ControllerVisibilityListener?) {
|
||||
playerView.setControllerVisibilityListener(listener)
|
||||
}
|
||||
|
||||
override fun addOnLayoutChangeListener(listener: View.OnLayoutChangeListener) {
|
||||
playerView.addOnLayoutChangeListener(listener)
|
||||
}
|
||||
|
||||
override fun setFocusable(focusable: Boolean) {
|
||||
playerView.isFocusable = focusable
|
||||
}
|
||||
|
||||
private fun updateLiveUi() {
|
||||
val player = playerView.player ?: return
|
||||
val isLive = player.isCurrentMediaItemLive
|
||||
val seekable = player.isCurrentMediaItemSeekable
|
||||
|
||||
// Show/hide badge
|
||||
liveBadge.visibility = if (isLive) View.VISIBLE else View.GONE
|
||||
|
||||
// Disable/enable scrubbing based on seekable
|
||||
val timeBar = playerView.findViewById<DefaultTimeBar?>(androidx.media3.ui.R.id.exo_progress)
|
||||
timeBar?.isEnabled = !isLive || seekable
|
||||
}
|
||||
|
||||
private val playerListener = object : Player.Listener {
|
||||
override fun onCues(cueGroup: CueGroup) {
|
||||
// Keep overlay subtitles in sync. This does NOT interfere with PlayerView's own subtitle rendering.
|
||||
|
|
@ -375,61 +311,15 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
|
|||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
playerView.post {
|
||||
playerView.requestLayout()
|
||||
// Reapply resize mode to ensure it's properly set after timeline changes
|
||||
pendingResizeMode?.let { resizeMode ->
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
}
|
||||
updateLiveUi()
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION) ||
|
||||
events.contains(Player.EVENT_IS_PLAYING_CHANGED)
|
||||
) {
|
||||
updateLiveUi()
|
||||
}
|
||||
|
||||
// Handle video size changes which affect aspect ratio
|
||||
if (events.contains(Player.EVENT_VIDEO_SIZE_CHANGED)) {
|
||||
pendingResizeMode?.let { resizeMode ->
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
playerView.requestLayout()
|
||||
requestLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ExoPlayerView"
|
||||
}
|
||||
|
||||
/**
|
||||
* React Native (Yoga) can sometimes defer layout passes that are required by
|
||||
* PlayerView for its child views (controller overlay, surface view, subtitle view, …).
|
||||
* This helper forces a second measure / layout after RN finishes, ensuring the
|
||||
* internal views receive the final size. The same approach is used in the v7
|
||||
* implementation (see VideoView.kt) and in React Native core (Toolbar example [link]).
|
||||
*/
|
||||
private val layoutRunnable = Runnable {
|
||||
measure(
|
||||
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
|
||||
)
|
||||
layout(left, top, right, bottom)
|
||||
}
|
||||
|
||||
override fun requestLayout() {
|
||||
super.requestLayout()
|
||||
// Post a second layout pass so the ExoPlayer internal views get correct bounds.
|
||||
post(layoutRunnable)
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
|
||||
if (changed) {
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
val width = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val height = MeasureSpec.getSize(heightMeasureSpec)
|
||||
if (width > 0 && height > 0) {
|
||||
pendingResizeMode?.let { resizeMode ->
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import Animated, { FadeIn, Layout } from 'react-native-reanimated';
|
|||
import { useCalendarData } from '../../hooks/useCalendarData';
|
||||
import { memoryManager } from '../../utils/memoryManager';
|
||||
import { tmdbService } from '../../services/tmdbService';
|
||||
import { CalendarEpisode } from '../../types/calendar';
|
||||
|
||||
// Compute base sizes; actual tablet sizes will be adjusted inside component for responsiveness
|
||||
const { width } = Dimensions.get('window');
|
||||
|
|
@ -43,7 +44,7 @@ interface ThisWeekEpisode {
|
|||
seriesName: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
releaseDate: string;
|
||||
releaseDate: string | null;
|
||||
season: number;
|
||||
episode: number;
|
||||
isReleased: boolean;
|
||||
|
|
|
|||
350
src/components/mal/MalEditModal.tsx
Normal file
350
src/components/mal/MalEditModal.tsx
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Switch,
|
||||
} from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { MalApiService } from '../../services/mal/MalApi';
|
||||
import { MalListStatus, MalAnimeNode } from '../../types/mal';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
|
||||
interface MalEditModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
anime: MalAnimeNode;
|
||||
onUpdateSuccess: () => void;
|
||||
}
|
||||
|
||||
export const MalEditModal: React.FC<MalEditModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
anime,
|
||||
onUpdateSuccess,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const [status, setStatus] = useState<MalListStatus>(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 (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={[styles.modalContent, { backgroundColor: currentTheme.colors.darkGray || '#1A1A1A' }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]} numberOfLines={1}>
|
||||
Edit {anime.node.title}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<MaterialIcons name="close" size={24} color={currentTheme.colors.mediumEmphasis} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
<Text style={[styles.label, { color: currentTheme.colors.mediumEmphasis }]}>Status</Text>
|
||||
<View style={styles.statusGrid}>
|
||||
{statusOptions.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
style={[
|
||||
styles.statusChip,
|
||||
{ borderColor: currentTheme.colors.border },
|
||||
status === option.value && {
|
||||
backgroundColor: currentTheme.colors.primary,
|
||||
borderColor: currentTheme.colors.primary
|
||||
}
|
||||
]}
|
||||
onPress={() => setStatus(option.value)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.statusText,
|
||||
{ color: currentTheme.colors.highEmphasis },
|
||||
status === option.value && { color: 'white' }
|
||||
]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.inputRow}>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={[styles.label, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Episodes ({anime.node.num_episodes || '?'})
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, {
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
borderColor: currentTheme.colors.border,
|
||||
backgroundColor: currentTheme.colors.elevation1
|
||||
}]}
|
||||
value={episodes}
|
||||
onChangeText={setEpisodes}
|
||||
keyboardType="numeric"
|
||||
placeholder="0"
|
||||
placeholderTextColor={currentTheme.colors.mediumEmphasis}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={[styles.label, { color: currentTheme.colors.mediumEmphasis }]}>Score (0-10)</Text>
|
||||
<TextInput
|
||||
style={[styles.input, {
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
borderColor: currentTheme.colors.border,
|
||||
backgroundColor: currentTheme.colors.elevation1
|
||||
}]}
|
||||
value={score}
|
||||
onChangeText={setScore}
|
||||
keyboardType="numeric"
|
||||
placeholder="0"
|
||||
placeholderTextColor={currentTheme.colors.mediumEmphasis}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.rewatchRow}>
|
||||
<View style={styles.rewatchTextContainer}>
|
||||
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis, marginTop: 0 }]}>Rewatching</Text>
|
||||
<Text style={[styles.rewatchDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Mark this if you are watching the series again.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isRewatching}
|
||||
onValueChange={setIsRewatching}
|
||||
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
|
||||
thumbColor={isRewatching ? currentTheme.colors.primary : '#f4f3f4'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.updateButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={handleUpdate}
|
||||
disabled={isUpdating || isRemoving}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text style={styles.updateButtonText}>Update MAL</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.removeButton, { borderColor: currentTheme.colors.error || '#FF5252' }]}
|
||||
onPress={handleRemove}
|
||||
disabled={isUpdating || isRemoving}
|
||||
>
|
||||
{isRemoving ? (
|
||||
<ActivityIndicator color={currentTheme.colors.error || '#FF5252'} />
|
||||
) : (
|
||||
<Text style={[styles.removeButtonText, { color: currentTheme.colors.error || '#FF5252' }]}>
|
||||
Remove from List
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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<HeroSectionProps> = memo(({
|
|||
{/* Optimized Action Buttons */}
|
||||
<ActionButtons
|
||||
handleShowStreams={handleShowStreams}
|
||||
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}
|
||||
/>
|
||||
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}
|
||||
/>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
|
|
|
|||
|
|
@ -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<SeriesContentProps> = ({
|
|||
|
||||
// 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<SeriesContentProps> = ({
|
|||
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',
|
||||
},
|
||||
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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<string, string>;
|
||||
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)}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<SkipInterval[]>([]);
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<number | null>(null);
|
||||
const [savedDuration, setSavedDuration] = useState<number | null>(null);
|
||||
|
|
@ -22,12 +32,40 @@ export const useWatchProgress = (
|
|||
const [showResumeOverlay, setShowResumeOverlay] = useState(false);
|
||||
const { settings: appSettings } = useSettings();
|
||||
const initialSeekTargetRef = useRef<number | null>(null);
|
||||
const hasScrobbledRef = useRef(false);
|
||||
const wasPausedRef = useRef<boolean>(paused);
|
||||
const [progressSaveInterval, setProgressSaveInterval] = useState<NodeJS.Timeout | null>(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(() => {
|
||||
|
|
|
|||
|
|
@ -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<SkipIntroButtonProps> = ({
|
|||
episode,
|
||||
malId,
|
||||
kitsuId,
|
||||
releaseDate,
|
||||
skipIntervals: externalSkipIntervals,
|
||||
currentTime,
|
||||
onSkip,
|
||||
|
|
@ -56,6 +58,7 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
|||
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<SkipIntroButtonProps> = ({
|
|||
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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MalSettings"
|
||||
component={MalSettingsScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'default' : 'fade',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||
presentation: 'card',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MalLibrary"
|
||||
component={MalLibraryScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'default' : 'fade',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||
presentation: 'card',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="SimklSettings"
|
||||
component={SimklSettingsScreen}
|
||||
|
|
|
|||
|
|
@ -31,30 +31,13 @@ import { tmdbService } from '../services/tmdbService';
|
|||
import { logger } from '../utils/logger';
|
||||
import { memoryManager } from '../utils/memoryManager';
|
||||
import { useCalendarData } from '../hooks/useCalendarData';
|
||||
import { AniListService } from '../services/anilist/AniListService';
|
||||
import { AniListAiringSchedule } from '../services/anilist/types';
|
||||
import { CalendarEpisode, CalendarSection } from '../types/calendar';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface CalendarSection {
|
||||
title: string;
|
||||
data: CalendarEpisode[];
|
||||
}
|
||||
|
||||
const CalendarScreen = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
|
|
@ -75,14 +58,90 @@ const CalendarScreen = () => {
|
|||
const [uiReady, setUiReady] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const [filteredEpisodes, setFilteredEpisodes] = useState<CalendarEpisode[]>([]);
|
||||
|
||||
// AniList Integration
|
||||
const [calendarSource, setCalendarSource] = useState<'nuvio' | 'anilist'>('nuvio');
|
||||
const [aniListSchedule, setAniListSchedule] = useState<CalendarSection[]>([]);
|
||||
const [aniListLoading, setAniListLoading] = useState(false);
|
||||
|
||||
const fetchAniListSchedule = useCallback(async () => {
|
||||
setAniListLoading(true);
|
||||
try {
|
||||
const schedule = await AniListService.getWeeklySchedule();
|
||||
|
||||
// Group by Day
|
||||
const grouped: Record<string, CalendarEpisode[]> = {};
|
||||
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 (
|
||||
<Animated.View entering={FadeIn.duration(300).delay(100)}>
|
||||
<TouchableOpacity
|
||||
|
|
@ -142,36 +203,53 @@ const CalendarScreen = () => {
|
|||
>
|
||||
<FastImage
|
||||
source={{ uri: imageUrl || '' }}
|
||||
style={styles.poster}
|
||||
style={[
|
||||
styles.poster,
|
||||
isAnimeItem && { aspectRatio: 2/3, width: 80, height: 120 }
|
||||
]}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.episodeDetails}>
|
||||
<Text style={[styles.seriesName, { color: currentTheme.colors.text }]} numberOfLines={1}>
|
||||
<Text style={[styles.seriesName, { color: currentTheme.colors.highEmphasis }]} numberOfLines={1}>
|
||||
{item.seriesName}
|
||||
</Text>
|
||||
|
||||
{hasReleaseDate ? (
|
||||
{(hasReleaseDate || isAnimeItem) ? (
|
||||
<>
|
||||
<Text style={[styles.episodeTitle, { color: currentTheme.colors.lightGray }]} numberOfLines={2}>
|
||||
S{item.season}:E{item.episode} - {item.title}
|
||||
</Text>
|
||||
{!isAnimeItem && (
|
||||
<Text style={[styles.episodeTitle, { color: currentTheme.colors.lightGray }]} numberOfLines={2}>
|
||||
S{item.season}:E{item.episode} - {item.title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{item.overview ? (
|
||||
<Text style={[styles.overview, { color: currentTheme.colors.lightGray }]} numberOfLines={2}>
|
||||
<Text style={[styles.overview, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={2}>
|
||||
{item.overview}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{isAnimeItem && item.genres && item.genres.length > 0 && (
|
||||
<View style={styles.genreContainer}>
|
||||
{item.genres.slice(0, 3).map((g, i) => (
|
||||
<View key={i} style={[styles.genreChip, { backgroundColor: currentTheme.colors.primary + '20' }]}>
|
||||
<Text style={[styles.genreText, { color: currentTheme.colors.primary }]}>{g}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.metadataContainer}>
|
||||
<View style={styles.dateContainer}>
|
||||
<MaterialIcons
|
||||
name={isFuture ? "event" : "event-available"}
|
||||
name={isFuture || isAnimeItem ? "event" : "event-available"}
|
||||
size={16}
|
||||
color={currentTheme.colors.lightGray}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
<Text style={[styles.date, { color: currentTheme.colors.lightGray }]}>{formattedDate}</Text>
|
||||
<Text style={[styles.date, { color: currentTheme.colors.primary, fontWeight: '600' }]}>
|
||||
{isAnimeItem ? `${item.day} ${item.time || ''}` : formattedDate}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{item.vote_average > 0 && (
|
||||
|
|
@ -179,9 +257,9 @@ const CalendarScreen = () => {
|
|||
<MaterialIcons
|
||||
name="star"
|
||||
size={16}
|
||||
color={currentTheme.colors.primary}
|
||||
color="#F5C518"
|
||||
/>
|
||||
<Text style={[styles.rating, { color: currentTheme.colors.primary }]}>
|
||||
<Text style={[styles.rating, { color: '#F5C518' }]}>
|
||||
{item.vote_average.toFixed(1)}
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -231,18 +309,38 @@ const CalendarScreen = () => {
|
|||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSourceSwitcher = () => (
|
||||
<View style={styles.tabContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.tabButton, calendarSource === 'nuvio' && { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={() => setCalendarSource('nuvio')}
|
||||
>
|
||||
<Text style={[styles.tabText, calendarSource === 'nuvio' && { color: '#fff', fontWeight: 'bold' }]}>Nuvio</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.tabButton, calendarSource === 'anilist' && { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={() => setCalendarSource('anilist')}
|
||||
>
|
||||
<Text style={[styles.tabText, calendarSource === 'anilist' && { color: '#fff', fontWeight: 'bold' }]}>AniList</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
|
|
@ -310,7 +408,11 @@ const CalendarScreen = () => {
|
|||
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>{t('calendar.title')}</Text>
|
||||
<View style={{ width: 40 }} />
|
||||
</View>
|
||||
|
||||
{renderSourceSwitcher()}
|
||||
|
||||
{calendarSource === 'nuvio' && (
|
||||
<>
|
||||
{selectedDate && filteredEpisodes.length > 0 && (
|
||||
<View style={[styles.filterInfoContainer, { borderBottomColor: currentTheme.colors.border }]}>
|
||||
<Text style={[styles.filterInfoText, { color: currentTheme.colors.text }]}>
|
||||
|
|
@ -326,6 +428,8 @@ const CalendarScreen = () => {
|
|||
episodes={allEpisodes}
|
||||
onSelectDate={handleDateSelect}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedDate && filteredEpisodes.length > 0 ? (
|
||||
<FlatList
|
||||
|
|
@ -362,9 +466,9 @@ const CalendarScreen = () => {
|
|||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : calendarData.length > 0 ? (
|
||||
) : (calendarSource === 'anilist' ? aniListSchedule : calendarData).length > 0 ? (
|
||||
<SectionList
|
||||
sections={calendarData}
|
||||
sections={calendarSource === 'anilist' ? aniListSchedule : calendarData}
|
||||
keyExtractor={(item) => 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;
|
||||
|
|
@ -246,6 +246,10 @@ const SkeletonLoader = () => {
|
|||
);
|
||||
};
|
||||
|
||||
import { MalApiService, MalSync, MalAnimeNode } from '../services/mal';
|
||||
|
||||
// ... other imports
|
||||
|
||||
const LibraryScreen = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
|
|
@ -254,8 +258,12 @@ const LibraryScreen = () => {
|
|||
const { numColumns, itemWidth } = useMemo(() => getGridLayout(width), [width]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [libraryItems, setLibraryItems] = useState<LibraryItem[]>([]);
|
||||
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<MalAnimeNode[]>([]);
|
||||
const [malLoading, setMalLoading] = useState(false);
|
||||
const [malOffset, setMalOffset] = useState(0);
|
||||
const [hasMoreMal, setHasMoreMal] = useState(true);
|
||||
const [selectedTraktFolder, setSelectedTraktFolder] = useState<string | null>(null);
|
||||
const [showSimklContent, setShowSimklContent] = useState(false);
|
||||
const [selectedSimklFolder, setSelectedSimklFolder] = useState<string | null>(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 }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.itemContainer, { width: itemWidth }]}
|
||||
onPress={() => navigation.navigate('Metadata', {
|
||||
id: `mal:${item.node.id}`,
|
||||
type: item.node.media_type === 'movie' ? 'movie' : 'series'
|
||||
})}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View>
|
||||
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black, borderRadius: settings.posterBorderRadius ?? 12 }]}>
|
||||
<FastImage
|
||||
source={{ uri: item.node.main_picture?.large || item.node.main_picture?.medium || 'https://via.placeholder.com/300x450' }}
|
||||
style={[styles.poster, { borderRadius: settings.posterBorderRadius ?? 12 }]}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<View style={styles.malBadge}>
|
||||
<Text style={styles.malBadgeText}>{item.list_status.status.replace('_', ' ')}</Text>
|
||||
</View>
|
||||
<View style={styles.progressBarContainer}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressBar,
|
||||
{
|
||||
width: `${(item.list_status.num_episodes_watched / (item.node.num_episodes || 1)) * 100}%`,
|
||||
backgroundColor: '#2E51A2'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={2}>
|
||||
{item.node.title}
|
||||
</Text>
|
||||
<Text style={[styles.malScore, { color: '#F5C518' }]}>
|
||||
★ {item.list_status.score > 0 ? item.list_status.score : '-'}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const renderSimklCollectionFolder = ({ folder }: { folder: TraktFolder }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.itemContainer, { width: itemWidth }]}
|
||||
|
|
@ -1501,6 +1571,79 @@ const LibraryScreen = () => {
|
|||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const renderMalContent = () => {
|
||||
if (malLoading && malList.length === 0) return <SkeletonLoader />;
|
||||
|
||||
if (malList.length === 0) {
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<MaterialIcons name="library-books" size={64} color={currentTheme.colors.lightGray} />
|
||||
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>Your MAL list is empty</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.exploreButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={() => loadMalList()}
|
||||
>
|
||||
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Refresh</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.listContainer, { paddingBottom: insets.bottom + 80 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={({ nativeEvent }) => {
|
||||
if (isCloseToBottom(nativeEvent) && hasMoreMal) {
|
||||
loadMalList(true);
|
||||
}
|
||||
}}
|
||||
scrollEventThrottle={400}
|
||||
>
|
||||
{sections.map(section => (
|
||||
section.data.length > 0 && (
|
||||
<View key={section.key} style={styles.malSectionContainer}>
|
||||
<Text style={[styles.malSectionHeader, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{section.title} <Text style={{ color: currentTheme.colors.mediumEmphasis, fontSize: 14 }}>({section.data.length})</Text>
|
||||
</Text>
|
||||
<View style={styles.malSectionGrid}>
|
||||
{section.data.map(item => (
|
||||
<View key={item.node.id} style={{ marginBottom: 16 }}>
|
||||
{renderMalItem({ item })}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
))}
|
||||
{malLoading && (
|
||||
<ActivityIndicator color={currentTheme.colors.primary} style={{ marginTop: 20 }} />
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
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 && (
|
||||
<MaterialIcons name={iconName} size={20} color={isActive ? currentTheme.colors.white : currentTheme.colors.mediumGray} style={styles.filterIcon} />
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
styles.filterText,
|
||||
|
|
@ -1647,6 +1799,7 @@ const LibraryScreen = () => {
|
|||
);
|
||||
};
|
||||
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return <SkeletonLoader />;
|
||||
|
|
@ -1742,15 +1895,21 @@ const LibraryScreen = () => {
|
|||
|
||||
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
{!showTraktContent && !showSimklContent && (
|
||||
<View style={styles.filtersContainer}>
|
||||
{renderFilter('trakt', 'Trakt')}
|
||||
{renderFilter('simkl', 'SIMKL')}
|
||||
{renderFilter('movies', t('search.movies'))}
|
||||
{renderFilter('series', t('search.tv_shows'))}
|
||||
</View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.filtersContainer}
|
||||
contentContainerStyle={styles.filtersContent}
|
||||
>
|
||||
{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')}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{showTraktContent ? renderTraktContent() : showSimklContent ? renderSimklContent() : renderContent()}
|
||||
{showTraktContent ? renderTraktContent() : showSimklContent ? renderSimklContent() : (filter === 'mal' ? renderMalContent() : renderContent())}
|
||||
</View>
|
||||
|
||||
{/* 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,
|
||||
|
|
|
|||
318
src/screens/MalLibraryScreen.tsx
Normal file
318
src/screens/MalLibraryScreen.tsx
Normal file
|
|
@ -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<any>();
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [groupedList, setGroupedList] = useState<Record<MalListStatus, MalAnimeNode[]>>({
|
||||
watching: [],
|
||||
completed: [],
|
||||
on_hold: [],
|
||||
dropped: [],
|
||||
plan_to_watch: [],
|
||||
});
|
||||
|
||||
const [selectedAnime, setSelectedAnime] = useState<MalAnimeNode | null>(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<MalListStatus, MalAnimeNode[]> = {
|
||||
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 }) => (
|
||||
<TouchableOpacity
|
||||
style={styles.animeItem}
|
||||
onPress={() => handleItemPress(item)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<FastImage
|
||||
source={{ uri: item.node.main_picture?.medium }}
|
||||
style={styles.poster}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<View style={styles.badgeContainer}>
|
||||
<View style={[styles.episodeBadge, { backgroundColor: currentTheme.colors.primary }]}>
|
||||
<Text style={styles.episodeText}>
|
||||
{item.list_status.num_episodes_watched} / {item.node.num_episodes || '?'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.animeTitle, { color: currentTheme.colors.highEmphasis }]} numberOfLines={2}>
|
||||
{item.node.title}
|
||||
</Text>
|
||||
{item.list_status.score > 0 && (
|
||||
<View style={styles.scoreRow}>
|
||||
<MaterialIcons name="star" size={12} color="#FFD700" />
|
||||
<Text style={[styles.scoreText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{item.list_status.score}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Requirement 5: Manual update button */}
|
||||
<TouchableOpacity
|
||||
style={styles.editButton}
|
||||
onPress={() => {
|
||||
setSelectedAnime(item);
|
||||
setIsEditModalVisible(true);
|
||||
}}
|
||||
>
|
||||
<MaterialIcons name="edit" size={16} color={currentTheme.colors.white} />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const renderSection = (status: MalListStatus, title: string, icon: string) => {
|
||||
const data = groupedList[status];
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.sectionContainer}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialIcons name={icon as any} size={20} color={currentTheme.colors.primary} style={{ marginRight: 8 }} />
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{title} ({data.length})
|
||||
</Text>
|
||||
</View>
|
||||
<FlatList
|
||||
data={data}
|
||||
renderItem={renderAnimeItem}
|
||||
keyExtractor={item => item.node.id.toString()}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.carouselContent}
|
||||
snapToInterval={ITEM_WIDTH + 12}
|
||||
decelerationRate="fast"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
<StatusBar
|
||||
barStyle="light-content"
|
||||
backgroundColor="transparent"
|
||||
translucent
|
||||
/>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}>
|
||||
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.highEmphasis} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
MyAnimeList
|
||||
</Text>
|
||||
{/* Requirement 6: Manual Sync Button */}
|
||||
<TouchableOpacity onPress={handleRefresh} style={styles.syncButton} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
|
||||
) : (
|
||||
<MaterialIcons name="sync" size={24} color={currentTheme.colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{!isLoading || isRefreshing ? (
|
||||
<ScrollView
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isRefreshing} onRefresh={handleRefresh} tintColor={currentTheme.colors.primary} />
|
||||
}
|
||||
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')}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{selectedAnime && (
|
||||
<MalEditModal
|
||||
visible={isEditModalVisible}
|
||||
anime={selectedAnime}
|
||||
onClose={() => {
|
||||
setIsEditModalVisible(false);
|
||||
setSelectedAnime(null);
|
||||
}}
|
||||
onUpdateSuccess={fetchMalList}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
555
src/screens/MalSettingsScreen.tsx
Normal file
555
src/screens/MalSettingsScreen.tsx
Normal file
|
|
@ -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<MalUser | null>(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<Array<{ label: string; onPress: () => 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 (
|
||||
<SafeAreaView style={[
|
||||
styles.container,
|
||||
{ backgroundColor: currentTheme.colors.darkBackground }
|
||||
]}>
|
||||
<StatusBar barStyle={'light-content'} />
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="arrow-back"
|
||||
size={24}
|
||||
color={currentTheme.colors.highEmphasis}
|
||||
/>
|
||||
<Text style={[styles.backText, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Settings
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
MyAnimeList
|
||||
</Text>
|
||||
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent}>
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
) : isAuthenticated && userProfile ? (
|
||||
<View style={styles.profileContainer}>
|
||||
<View style={styles.profileHeader}>
|
||||
{userProfile.picture ? (
|
||||
<FastImage
|
||||
source={{ uri: userProfile.picture }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.avatarPlaceholder, { backgroundColor: currentTheme.colors.primary }]}>
|
||||
<Text style={styles.avatarText}>{userProfile.name.charAt(0)}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.profileInfo}>
|
||||
<Text style={[styles.profileName, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{userProfile.name}
|
||||
</Text>
|
||||
<View style={styles.profileDetailRow}>
|
||||
<MaterialIcons name="fingerprint" size={14} color={currentTheme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.profileDetailText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
ID: {userProfile.id}
|
||||
</Text>
|
||||
</View>
|
||||
{userProfile.location && (
|
||||
<View style={styles.profileDetailRow}>
|
||||
<MaterialIcons name="location-on" size={14} color={currentTheme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.profileDetailText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{userProfile.location}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{userProfile.birthday && (
|
||||
<View style={styles.profileDetailRow}>
|
||||
<MaterialIcons name="cake" size={14} color={currentTheme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.profileDetailText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{userProfile.birthday}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{userProfile.anime_statistics && (
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statBox}>
|
||||
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
|
||||
{userProfile.anime_statistics.num_items}
|
||||
</Text>
|
||||
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>Total</Text>
|
||||
</View>
|
||||
<View style={styles.statBox}>
|
||||
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
|
||||
{userProfile.anime_statistics.num_days_watched.toFixed(1)}
|
||||
</Text>
|
||||
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>Days</Text>
|
||||
</View>
|
||||
<View style={styles.statBox}>
|
||||
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
|
||||
{userProfile.anime_statistics.mean_score.toFixed(1)}
|
||||
</Text>
|
||||
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>Mean</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.statGrid, { borderColor: currentTheme.colors.border }]}>
|
||||
<View style={styles.statGridItem}>
|
||||
<View style={[styles.statusDot, { backgroundColor: '#2DB039' }]} />
|
||||
<Text style={[styles.statGridLabel, { color: currentTheme.colors.highEmphasis }]}>Watching</Text>
|
||||
<Text style={[styles.statGridValue, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{userProfile.anime_statistics.num_items_watching}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.statGridItem}>
|
||||
<View style={[styles.statusDot, { backgroundColor: '#26448F' }]} />
|
||||
<Text style={[styles.statGridLabel, { color: currentTheme.colors.highEmphasis }]}>Completed</Text>
|
||||
<Text style={[styles.statGridValue, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{userProfile.anime_statistics.num_items_completed}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.statGridItem}>
|
||||
<View style={[styles.statusDot, { backgroundColor: '#F9D457' }]} />
|
||||
<Text style={[styles.statGridLabel, { color: currentTheme.colors.highEmphasis }]}>On Hold</Text>
|
||||
<Text style={[styles.statGridValue, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{userProfile.anime_statistics.num_items_on_hold}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.statGridItem}>
|
||||
<View style={[styles.statusDot, { backgroundColor: '#A12F31' }]} />
|
||||
<Text style={[styles.statGridLabel, { color: currentTheme.colors.highEmphasis }]}>Dropped</Text>
|
||||
<Text style={[styles.statGridValue, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{userProfile.anime_statistics.num_items_dropped}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.actionButtonsRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.smallButton, { backgroundColor: currentTheme.colors.primary, flex: 1, marginRight: 8 }]}
|
||||
onPress={async () => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MaterialIcons name="sync" size={18} color="white" style={{ marginRight: 6 }} />
|
||||
<Text style={styles.buttonText}>Sync</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.smallButton, { backgroundColor: currentTheme.colors.error, width: 100 }]}
|
||||
onPress={handleSignOut}
|
||||
>
|
||||
<Text style={styles.buttonText}>Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.signInContainer}>
|
||||
<Image
|
||||
source={require('../../assets/rating-icons/mal-icon.png')}
|
||||
style={{ width: 80, height: 80, marginBottom: 16, borderRadius: 16 }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={[styles.signInTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Connect MyAnimeList
|
||||
</Text>
|
||||
<Text style={[styles.signInDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Sync your watch history and manage your anime list.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={handleSignIn}
|
||||
>
|
||||
<Text style={styles.buttonText}>Sign In with MAL</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{isAuthenticated && (
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.settingsSection}>
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Sync Settings
|
||||
</Text>
|
||||
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingContent}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Enable MAL Sync
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Global switch to enable or disable all MyAnimeList features.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={syncEnabled}
|
||||
onValueChange={toggleSync}
|
||||
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
|
||||
thumbColor={syncEnabled ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingContent}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Auto Episode Update
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Automatically update your progress on MAL when you finish watching an episode (>=90% completion).
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={autoUpdateEnabled}
|
||||
onValueChange={toggleAutoUpdate}
|
||||
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
|
||||
thumbColor={autoUpdateEnabled ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingContent}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Auto Add Anime
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
If an anime is not in your MAL list, it will be added automatically when you start watching.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={autoAddEnabled}
|
||||
onValueChange={toggleAutoAdd}
|
||||
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
|
||||
thumbColor={autoAddEnabled ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingContent}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Auto-Sync to Library
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Automatically add items from your MAL 'Watching' list to your Nuvio Library.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={autoLibrarySyncEnabled}
|
||||
onValueChange={toggleAutoLibrarySync}
|
||||
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
|
||||
thumbColor={autoLibrarySyncEnabled ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingContent}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Include NSFW Content
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Allow NSFW entries to be returned when fetching your MAL list.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={includeNsfwEnabled}
|
||||
onValueChange={toggleIncludeNsfw}
|
||||
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
|
||||
thumbColor={includeNsfwEnabled ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<CustomAlert
|
||||
visible={alertVisible}
|
||||
title={alertTitle}
|
||||
message={alertMessage}
|
||||
onClose={() => setAlertVisible(false)}
|
||||
actions={alertActions}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
|
|
@ -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<RouteProp<Record<string, RouteParams & { episodeId?: string; addonId?: string }>, string>>();
|
||||
useEffect(() => { console.log('✅ MetadataScreen MOUNTED'); }, []);
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const route = useRoute<RouteProp<RootStackParamList, 'Metadata'>>();
|
||||
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<any>(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<any>(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 (
|
||||
<MetadataLoadingScreen
|
||||
ref={loadingScreenRef}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
ScrollView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||
import { useNavigation, useFocusEffect, useRoute } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
|
|
@ -63,6 +63,7 @@ const SearchScreen = () => {
|
|||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const route = useRoute<any>();
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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={() => <ChevronRight />}
|
||||
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={<TraktIcon size={isTablet ? 24 : 20} />}
|
||||
renderControl={() => <ChevronRight />}
|
||||
onPress={() => navigation.navigate('TraktSettings')}
|
||||
isLast={!showSimklItem}
|
||||
isLast={!showSimklItem && !isItemVisible('mal')}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -413,6 +414,17 @@ const SettingsScreen: React.FC = () => {
|
|||
customIcon={<SimklIcon size={isTablet ? 24 : 20} />}
|
||||
renderControl={() => <ChevronRight />}
|
||||
onPress={() => navigation.navigate('SimklSettings')}
|
||||
isLast={!isItemVisible('mal')}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
)}
|
||||
{isItemVisible('mal') && (
|
||||
<SettingItem
|
||||
title="MyAnimeList"
|
||||
description="Sync with MyAnimeList"
|
||||
customIcon={<Image source={require('../../assets/rating-icons/mal-icon.png')} style={{ width: isTablet ? 24 : 20, height: isTablet ? 24 : 20, borderRadius: 4 }} resizeMode="contain" />}
|
||||
renderControl={() => <ChevronRight />}
|
||||
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')) && (
|
||||
<SettingsCard title={t('settings.account').toUpperCase()}>
|
||||
{showCloudSyncItem && (
|
||||
<SettingItem
|
||||
|
|
@ -716,7 +728,7 @@ const SettingsScreen: React.FC = () => {
|
|||
}
|
||||
renderControl={() => <ChevronRight />}
|
||||
onPress={() => (navigation as any).navigate('SyncSettings')}
|
||||
isLast={!showTraktItem && !showSimklItem}
|
||||
isLast={!showTraktItem && !showSimklItem && !isItemVisible('mal')}
|
||||
/>
|
||||
)}
|
||||
{showTraktItem && (
|
||||
|
|
@ -726,7 +738,7 @@ const SettingsScreen: React.FC = () => {
|
|||
customIcon={<TraktIcon size={20} />}
|
||||
renderControl={() => <ChevronRight />}
|
||||
onPress={() => navigation.navigate('TraktSettings')}
|
||||
isLast={!showSimklItem}
|
||||
isLast={!showSimklItem && !isItemVisible('mal')}
|
||||
/>
|
||||
)}
|
||||
{showSimklItem && (
|
||||
|
|
@ -736,7 +748,17 @@ const SettingsScreen: React.FC = () => {
|
|||
customIcon={<SimklIcon size={20} />}
|
||||
renderControl={() => <ChevronRight />}
|
||||
onPress={() => navigation.navigate('SimklSettings')}
|
||||
isLast={true}
|
||||
isLast={!isItemVisible('mal')}
|
||||
/>
|
||||
)}
|
||||
{isItemVisible('mal') && (
|
||||
<SettingItem
|
||||
title="MyAnimeList"
|
||||
description="Sync with MyAnimeList"
|
||||
customIcon={<Image source={require('../../assets/rating-icons/mal-icon.png')} style={{ width: 20, height: 20, borderRadius: 4 }} resizeMode="contain" />}
|
||||
renderControl={() => <ChevronRight />}
|
||||
onPress={() => navigation.navigate('MalSettings')}
|
||||
isLast
|
||||
/>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
83
src/services/anilist/AniListService.ts
Normal file
83
src/services/anilist/AniListService.ts
Normal file
|
|
@ -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<AniListAiringSchedule[]> => {
|
||||
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<AniListResponse>(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;
|
||||
}
|
||||
},
|
||||
};
|
||||
44
src/services/anilist/types.ts
Normal file
44
src/services/anilist/types.ts
Normal file
|
|
@ -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[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<SkipInterval[]> {
|
||||
// 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;
|
||||
192
src/services/mal/ArmSyncService.ts
Normal file
192
src/services/mal/ArmSyncService.ts
Normal file
|
|
@ -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<DateSyncResult | null>} The resolved MAL ID and Episode number
|
||||
*/
|
||||
resolveByDate: async (imdbId: string, releaseDateStr: string, dayIndex?: number): Promise<DateSyncResult | null> => {
|
||||
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<ArmEntry[]>(`${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<DateSyncResult | null>} The resolved MAL ID and Episode number
|
||||
*/
|
||||
resolveByTmdb: async (tmdbId: number, releaseDateStr: string, dayIndex?: number): Promise<DateSyncResult | null> => {
|
||||
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<ArmEntry[]>(`${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<DateSyncResult | null> => {
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
125
src/services/mal/MalApi.ts
Normal file
125
src/services/mal/MalApi.ts
Normal file
|
|
@ -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<MalUserListResponse> => {
|
||||
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<MalSearchResult> => {
|
||||
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<MalUser> => {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
};
|
||||
260
src/services/mal/MalAuth.ts
Normal file
260
src/services/mal/MalAuth.ts
Normal file
|
|
@ -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<boolean | string> {
|
||||
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<boolean> {
|
||||
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();
|
||||
561
src/services/mal/MalSync.ts
Normal file
561
src/services/mal/MalSync.ts
Normal file
|
|
@ -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<number | null> => {
|
||||
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<number | null> => {
|
||||
// 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<any[]> => {
|
||||
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<string, number> = { '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<string, any[]> = {};
|
||||
|
||||
// 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 [];
|
||||
}
|
||||
}
|
||||
};
|
||||
4
src/services/mal/index.ts
Normal file
4
src/services/mal/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from '../../types/mal';
|
||||
export * from './MalAuth';
|
||||
export * from './MalApi';
|
||||
export * from './MalSync';
|
||||
|
|
@ -1270,29 +1270,75 @@ class LocalScraperService {
|
|||
}
|
||||
|
||||
|
||||
private async executePlugin(code: string, params: any, consoleOverride?: any): Promise<LocalScraperResult[]> {
|
||||
// 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<any> {
|
||||
return this.executePluginInternal(code, params, logger);
|
||||
}
|
||||
|
||||
private async executePluginInternal(code: string, params: any, customLogger: any): Promise<any> {
|
||||
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<LocalScraperResult[]>((resolve, reject) => {
|
||||
const executionPromise = new Promise<any>((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<any[]> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
23
src/types/calendar.ts
Normal file
23
src/types/calendar.ts
Normal file
|
|
@ -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[];
|
||||
}
|
||||
80
src/types/mal.ts
Normal file
80
src/types/mal.ts
Normal file
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue