diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index ef70bbc4..8cd85b38 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -147,7 +147,7 @@ const ContinueWatchingSection = React.forwardRef((props, re isRefreshingRef.current = true; // Helper to merge a batch of items into state (dedupe by type:id, keep newest) - const mergeBatchIntoState = (batch: ContinueWatchingItem[]) => { + const mergeBatchIntoState = async (batch: ContinueWatchingItem[]) => { if (!batch || batch.length === 0) return; setContinueWatchingItems((prev) => { @@ -156,25 +156,45 @@ const ContinueWatchingSection = React.forwardRef((props, re map.set(`${it.type}:${it.id}`, it); } - for (const it of batch) { - const key = `${it.type}:${it.id}`; - - // Skip recently removed items to prevent immediate re-addition - if (recentlyRemovedRef.current.has(key)) { - continue; - } - - const existing = map.get(key); - if (!existing || (it.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { - map.set(key, it); - } - } - const merged = Array.from(map.values()); merged.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0)); return merged; }); + + // Process batch items asynchronously to check removal status + for (const it of batch) { + const key = `${it.type}:${it.id}`; + + // Skip recently removed items to prevent immediate re-addition + if (recentlyRemovedRef.current.has(key)) { + continue; + } + + // Skip items that have been persistently marked as removed + const isRemoved = await storageService.isContinueWatchingRemoved(it.id, it.type); + if (isRemoved) { + continue; + } + + // Add the item to state + setContinueWatchingItems((prev) => { + const map = new Map(); + for (const existing of prev) { + map.set(`${existing.type}:${existing.id}`, existing); + } + + const existing = map.get(key); + if (!existing || (it.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { + map.set(key, it); + const merged = Array.from(map.values()); + merged.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0)); + return merged; + } + + return prev; + }); + } }; try { @@ -288,7 +308,7 @@ const ContinueWatchingSection = React.forwardRef((props, re } as ContinueWatchingItem); } - if (batch.length > 0) mergeBatchIntoState(batch); + if (batch.length > 0) await mergeBatchIntoState(batch); } catch (error) { // Continue processing other groups even if one fails } @@ -337,7 +357,7 @@ const ContinueWatchingSection = React.forwardRef((props, re } if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) { logger.log(`➕ [TraktSync] Adding next episode for ${showId}: S${info.season}E${nextEpisode}`); - mergeBatchIntoState([ + await mergeBatchIntoState([ { ...basicContent, id: showId, @@ -536,6 +556,9 @@ const ContinueWatchingSection = React.forwardRef((props, re const itemKey = `${item.type}:${item.id}`; recentlyRemovedRef.current.add(itemKey); + // Persist the removed state for long-term tracking + await storageService.addContinueWatchingRemoved(item.id, item.type); + // Clear from recently removed after the ignore duration setTimeout(() => { recentlyRemovedRef.current.delete(itemKey); diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts index e4c53827..57b5eba3 100644 --- a/src/services/SyncService.ts +++ b/src/services/SyncService.ts @@ -427,6 +427,12 @@ class SyncService { await AsyncStorage.setItem(`@user:${userId}:app_settings`, JSON.stringify(mergedSettings)); await AsyncStorage.setItem('app_settings', JSON.stringify(mergedSettings)); + + // Sync continue watching removed items (stored in app_settings) + if (remoteSettingsSansLocalOnly?.continue_watching_removed) { + await AsyncStorage.setItem(`@user:${userId}:@continue_watching_removed`, JSON.stringify(remoteSettingsSansLocalOnly.continue_watching_removed)); + } + await storageService.saveSubtitleSettings(us.subtitle_settings || {}); // Notify listeners that settings changed due to sync try { settingsEmitter.emit(); } catch {} @@ -436,6 +442,12 @@ class SyncService { const { episodeLayoutStyle: _remoteEpisodeLayoutStyle, ...remoteSettingsSansLocalOnly } = remoteRaw || {}; await AsyncStorage.setItem(`@user:${userId}:app_settings`, JSON.stringify(remoteSettingsSansLocalOnly)); await AsyncStorage.setItem('app_settings', JSON.stringify(remoteSettingsSansLocalOnly)); + + // Sync continue watching removed items in fallback (stored in app_settings) + if (remoteSettingsSansLocalOnly?.continue_watching_removed) { + await AsyncStorage.setItem(`@user:${userId}:@continue_watching_removed`, JSON.stringify(remoteSettingsSansLocalOnly.continue_watching_removed)); + } + await storageService.saveSubtitleSettings(us.subtitle_settings || {}); try { settingsEmitter.emit(); } catch {} } @@ -762,9 +774,17 @@ class SyncService { // Exclude local-only settings from push const { episodeLayoutStyle: _localEpisodeLayoutStyle, ...appSettings } = parsed || {}; const subtitleSettings = (await storageService.getSubtitleSettings()) || {}; + const continueWatchingRemoved = await storageService.getContinueWatchingRemoved(); + + // Include continue watching removed items in app_settings + const appSettingsWithRemoved = { + ...appSettings, + continue_watching_removed: continueWatchingRemoved + }; + const { error } = await supabase.from('user_settings').upsert({ user_id: userId, - app_settings: appSettings, + app_settings: appSettingsWithRemoved, subtitle_settings: subtitleSettings, }); if (error && __DEV__) console.warn('[SyncService] push settings error', error); diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 7c66ba51..b1b8e73c 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -16,6 +16,7 @@ class StorageService { private readonly CONTENT_DURATION_KEY = '@content_duration:'; private readonly SUBTITLE_SETTINGS_KEY = '@subtitle_settings'; private readonly WP_TOMBSTONES_KEY = '@wp_tombstones'; + private readonly CONTINUE_WATCHING_REMOVED_KEY = '@continue_watching_removed'; private watchProgressSubscribers: (() => void)[] = []; private watchProgressRemoveListeners: ((id: string, type: string, episodeId?: string) => void)[] = []; private notificationDebounceTimer: NodeJS.Timeout | null = null; @@ -61,6 +62,11 @@ class StorageService { return `@user:${scope}:${this.WP_TOMBSTONES_KEY}`; } + private async getContinueWatchingRemovedKeyScoped(): Promise { + const scope = await this.getUserScope(); + return `@user:${scope}:${this.CONTINUE_WATCHING_REMOVED_KEY}`; + } + private buildWpKeyString(id: string, type: string, episodeId?: string): string { return `${type}:${id}${episodeId ? `:${episodeId}` : ''}`; } @@ -107,6 +113,61 @@ class StorageService { } } + public async addContinueWatchingRemoved( + id: string, + type: string, + removedAtMs?: number + ): Promise { + try { + const key = await this.getContinueWatchingRemovedKeyScoped(); + const json = (await AsyncStorage.getItem(key)) || '{}'; + const map = JSON.parse(json) as Record; + map[this.buildWpKeyString(id, type)] = removedAtMs || Date.now(); + await AsyncStorage.setItem(key, JSON.stringify(map)); + } catch (error) { + logger.error('Error adding continue watching removed item:', error); + } + } + + public async removeContinueWatchingRemoved( + id: string, + type: string + ): Promise { + try { + const key = await this.getContinueWatchingRemovedKeyScoped(); + const json = (await AsyncStorage.getItem(key)) || '{}'; + const map = JSON.parse(json) as Record; + const k = this.buildWpKeyString(id, type); + if (map[k] != null) { + delete map[k]; + await AsyncStorage.setItem(key, JSON.stringify(map)); + } + } catch (error) { + logger.error('Error removing continue watching removed item:', error); + } + } + + public async getContinueWatchingRemoved(): Promise> { + try { + const key = await this.getContinueWatchingRemovedKeyScoped(); + const json = (await AsyncStorage.getItem(key)) || '{}'; + return JSON.parse(json) as Record; + } catch (error) { + logger.error('Error getting continue watching removed items:', error); + return {}; + } + } + + public async isContinueWatchingRemoved(id: string, type: string): Promise { + try { + const removedItems = await this.getContinueWatchingRemoved(); + const key = this.buildWpKeyString(id, type); + return removedItems[key] != null; + } catch { + return false; + } + } + public async setContentDuration( id: string, type: string,