# Watched Page Architecture + Hide Watched Filter (2026-05-22)

## Room v4 Migration — WatchedItem Entity

Database bumped to v4 for unified watch history. Entity + migration:

```kotlin
@Entity(tableName = "watched_items", indices = [Index("imdbId")])
data class WatchedItem(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val imdbId: String,
    val tmdbId: String? = null,
    val title: String,
    val posterUrl: String? = null,
    val backdropUrl: String? = null,
    val source: String,          // "realdebrid", "premiumize", "torbox", "local"
    val sourceId: String? = null, // provider-specific ID
    val watchedAt: Long = System.currentTimeMillis(),
    val duration: Long? = null,  // playback duration ms
    val progress: Long? = null,  // position ms when stopped
    val profileId: Int = 0,
)
```

**MIGRATION_3_4:**
```kotlin
val MIGRATION_3_4 = object : Migration(3, 4) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("""CREATE TABLE IF NOT EXISTS watched_items (
            id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
            imdbId TEXT NOT NULL, tmdbId TEXT, title TEXT NOT NULL,
            posterUrl TEXT, backdropUrl TEXT, source TEXT NOT NULL,
            sourceId TEXT, watchedAt INTEGER NOT NULL,
            duration INTEGER, progress INTEGER, profileId INTEGER NOT NULL
        )""")
        db.execSQL("CREATE INDEX IF NOT EXISTS index_watched_items_imdbId ON watched_items(imdbId)")
    }
}
```

Register in `LocalDataModule.provideDatabase()`:
```kotlin
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
```

## WatchedDao

```kotlin
@Dao
interface WatchedDao {
    @Query("SELECT * FROM watched_items ORDER BY watchedAt DESC")
    fun getAll(): Flow<List<WatchedItem>>

    @Query("SELECT * FROM watched_items WHERE source = :source ORDER BY watchedAt DESC")
    fun getBySource(source: String): Flow<List<WatchedItem>>

    @Query("SELECT imdbId FROM watched_items")
    suspend fun getAllWatchedImdbIds(): List<String>

    @Query("SELECT EXISTS(SELECT 1 FROM watched_items WHERE imdbId = :imdbId)")
    suspend fun isWatched(imdbId: String): Boolean

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(item: WatchedItem)

    @Query("DELETE FROM watched_items WHERE id = :id")
    suspend fun deleteById(id: Long)

    @Query("DELETE FROM watched_items WHERE imdbId = :imdbId")
    suspend fun deleteByImdbId(imdbId: String)
}
```

## WatchedRepository — Unified Aggregation

```kotlin
class WatchedRepository @Inject constructor(
    private val watchedDao: WatchedDao,
    private val rdClient: RealDebridClient,
    private val pmClient: PremiumizeClient,
    private val torBoxClient: TorBoxClient,
    private val streamHistoryDao: StreamHistoryDao,
) {
    fun getWatchedFlow(): Flow<List<WatchedItem>> = watchedDao.getAll()

    suspend fun refreshFromDebrid() {
        // Pull cached torrents from each provider and upsert
        try {
            rdClient.getCachedTorrents().forEach {
                watchedDao.insert(it.toWatchedItem("realdebrid"))
            }
        } catch (_: Exception) { /* best-effort */ }
        // Same for pmClient, torBoxClient
    }

    suspend fun removeFromHistory(imdbId: String) {
        watchedDao.deleteByImdbId(imdbId)
    }
}
```

## WatchedViewModel

```kotlin
@HiltViewModel
class WatchedViewModel @Inject constructor(
    private val watchedRepository: WatchedRepository,
) : ViewModel() {

    private val _filter = MutableStateFlow("all") // "all", "realdebrid", "premiumize", "torbox", "local"
    val filter: StateFlow<String> = _filter.asStateFlow()

    val watchedItems: StateFlow<List<WatchedItem>> = _filter
        .flatMapLatest { filter ->
            if (filter == "all") watchedRepository.getWatchedFlow()
            else watchedDao.getBySource(filter)
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    fun setFilter(source: String) { _filter.value = source }
    fun remove(item: WatchedItem) = viewModelScope.launch {
        watchedRepository.removeFromHistory(item.imdbId)
    }
}
```

## WatchedScreen — Netflix-style Layout

Key UI elements:
- Filter chip row: All, Real-Debrid, Premiumize, TorBox, Local
- LazyVerticalGrid of poster cards with source badge overlay
- Long-press → "Remove from history" action
- Empty state: `NexEmptyState` with icon + message

## Hide Watched Filter — PreferencesManager

```kotlin
private val hideWatchedContentKey = booleanPreferencesKey("hide_watched_content")

val hideWatchedContent: Flow<Boolean> = dataStore.data
    .map { prefs -> prefs[hideWatchedContentKey] ?: false }

suspend fun setHideWatchedContent(enabled: Boolean) {
    dataStore.edit { prefs -> prefs[hideWatchedContentKey] = enabled }
}
```

## Hide Watched in ViewModels — Reactive Pattern (All 3 ViewModels)

**Wired in:** HomeViewModel, MoviesViewModel, TvShowsViewModel (commit `c3e70ed`, 2026-05-22).

**Pattern (identical across all three):**

```kotlin
// Constructor injects:
private val watchedDao: WatchedDao,
private val preferencesManager: PreferencesManager,

// State flag:
private var hideWatchedEnabled = false

// Init — observe toggle + reload:
init {
    preferencesManager.hideWatchedContent
        .onEach { enabled ->
            hideWatchedEnabled = enabled
            loadMovies()  // or loadHome() / loadTvShows()
        }
        .launchIn(viewModelScope)

    loadMovies()
}

// In each row loader — fetch watched IDs and filter:
private suspend fun loadRow(id: String, title: String, catalogType: String, catalogId: String): MovieRow {
    return try {
        val result = addonRepository.getCatalogRows(catalogType, catalogId)
        val items = result.getOrDefault(emptyList())
        val profile = getActiveProfile()
        val profileId = preferencesManager.activeProfileId.first()
        
        val watchedIds: Set<String> = if (hideWatchedEnabled) {
            try { watchedDao.getWatchedIds(profileId).toSet() } catch (_: Exception) { emptySet() }
        } else { emptySet() }
        
        val filtered = items.filter { item ->
            (profile == null || ContentFilter.isAppropriateFor(profile, item)) &&
                item.id !in watchedIds
        }
        MovieRow(id = id, title = title, items = filtered.take(20))
    } catch (e: Exception) {
        MovieRow(id = id, title = title, items = emptyList(), error = e.message)
    }
}
```

**Key rules:**
1. Use `watchedDao.getWatchedIds(profileId)` — NOT `getByProfile(profileId).map { it.mediaId }` (DAO method returns `List<String>` directly)
2. Wrap DAO call in `try/catch` — Room may not have data yet on fresh installs
3. The `hideWatchedEnabled` flag is updated reactively via `onEach` + `launchIn` — toggling the switch in Settings immediately reloads all screens
4. Apply the filter in EVERY row loader (trending, popular, top_rated, etc.) — not just one central function

**Required imports:**
```kotlin
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.launchIn
import com.nexstream.app.data.local.dao.WatchedDao
import com.nexstream.app.data.local.PreferencesManager
```

## Settings Hub Overhaul — Categorized Layout (commit `c3e70ed`)

The Settings screen was overhauled from a flat list to a rich, categorized grid matching the Netflix TV aesthetic.

**Structure:**
1. **Profile Hero Card** — shows active profile name, avatar, edit button
2. **Category Cards** — grouped settings sections:
   - Account (Debrid providers, Trakt, subtitle preferences)
   - Playback (quality, buffer, autoplay, subtitles toggle)
   - Home & Library (content filters, continue watching, watched page)
   - Appearance (theme, font size, animations, colorblind mode)
   - Privacy & History (hide watched, clear history, data export)
   - Notifications (updates, new content, download alerts)
   - Advanced (cache management, clear cache, debug logs)
   - About (version, licenses, updates, support)

**Key pattern — StatusBadge + CategoryCard:**
```kotlin
@Composable
private fun CategoryCard(
    title: String,
    icon: ImageVector,
    description: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Surface(
        onClick = onClick,
        modifier = modifier.fillMaxWidth().padding(8.dp),
        shape = RoundedCornerShape(16.dp),
        colors = ClickableSurfaceDefaults.colors(containerColor = NexColors.SurfaceHigh),
    ) {
        Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) {
            Icon(icon, contentDescription = null, tint = NexColors.Accent, modifier = Modifier.size(32.dp))
            Spacer(Modifier.width(16.dp))
            Column(modifier = Modifier.weight(1f)) {
                Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
                Text(description, style = MaterialTheme.typography.bodySmall, color = NexColors.TextSecondary, maxLines = 2)
            }
            Icon(Icons.Default.ChevronRight, contentDescription = null, tint = NexColors.TextSecondary)
        }
    }
}
```

**Toggle pattern (SettingsToggleItem):**
```kotlin
@Composable
private fun SettingsToggleItem(
    label: String,
    description: String,
    checked: Boolean,
    onCheckedChange: (Boolean) -> Unit,
    modifier: Modifier = Modifier,
) {
    Surface(
        onClick = { onCheckedChange(!checked) },
        modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
        shape = RoundedCornerShape(12.dp),
        colors = ClickableSurfaceDefaults.colors(containerColor = NexColors.Surface),
    ) {
        Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) {
            Column(modifier = Modifier.weight(1f)) {
                Text(label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
                Text(description, style = MaterialTheme.typography.bodySmall, color = NexColors.TextSecondary)
            }
            Switch(checked = checked, onCheckedChange = onCheckedChange)
        }
    }
}
```

**Required imports:**
```kotlin
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.Switch
import androidx.compose.ui.unit.dp
```

## Build Verification (Final State — commit `c3e70ed`)

```bash
cd /home/rurouni/nexstream
./gradlew compileDebugKotlin testDebugUnitTest
```

**Result:** BUILD SUCCESSFUL, 17 tests passed, 0 errors.

**Commit stats:** 21 files changed, +1802/-113 insertions.

## Navigation Wiring

- `Screen.Watched` added to `Screen.kt` with route `"watched"`
- `NexStreamNavHost.kt`: `composable(Screen.Watched.route) { WatchedScreen(navController) }`
- `NetflixSideNavigation.kt`: Added "Watched" item with `Icons.Default.History` icon
- `SidebarRouting.kt`: `"watched" -> navController.navigate(Screen.Watched.route)`

## Addons Page Crash Fix — DiscoverableAddon Type Mismatch

**Root cause:** `AddonsViewModel.kt` had an inner `data class DiscoverableAddon(...)` that shadowed the domain model. Different field names caused `ClassCastException` at runtime.

**Fix:** Remove the inner class entirely. Use `com.nexstream.app.domain.model.DiscoverableAddon` as the canonical type. Also fix `requiresDebrid` check:

```kotlin
// WRONG — requiresDebrid is String, not nullable:
if (addon.requiresDebrid != null) { ... }

// RIGHT:
if (addon.requiresDebrid.isNotBlank()) { ... }
```

## Coil3 Bitmap Conversion — Palette Extraction

```kotlin
// WRONG — Coil 2 API:
val bitmap = (state.result.drawable as BitmapDrawable).bitmap

// RIGHT — Coil 3 API:
import coil3.toBitmap
val bitmap = state.result.image.toBitmap()
```
