diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index fb53b209..165dabe2 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -324,6 +324,11 @@ dependencies {
implementation("androidx.hilt:hilt-work:1.1.0")
ksp("androidx.hilt:hilt-compiler:1.1.0")
+ // Room for download persistence
+ implementation("androidx.room:room-runtime:2.7.1")
+ implementation("androidx.room:room-ktx:2.7.1")
+ ksp("androidx.room:room-compiler:2.7.1")
+
// Profile installer for baseline profiles
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cdfc2550..6e01c09d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,6 +7,9 @@
+
+
+
+
+
diff --git a/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt b/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt
index 14788c84..c8f335da 100644
--- a/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt
+++ b/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt
@@ -80,6 +80,16 @@ class ArflixApplication : Application(), Configuration.Provider, ImageLoaderFact
super.onCreate()
instance = this
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+ val channel = android.app.NotificationChannel(
+ com.arflix.tv.worker.DownloadWorker.NOTIFICATION_CHANNEL_ID,
+ "Downloads",
+ android.app.NotificationManager.IMPORTANCE_LOW
+ )
+ getSystemService(android.app.NotificationManager::class.java)
+ .createNotificationChannel(channel)
+ }
+
// OkHttpProvider.init(context) just stashes the app context; it does
// not build the OkHttpClient. Safe to keep on the main thread — it's
// a single volatile assignment.
diff --git a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt
index c1556d15..0a1334c0 100644
--- a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt
+++ b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt
@@ -35,6 +35,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -53,8 +54,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.arflix.tv.ui.components.AppBottomBar
import androidx.core.view.WindowCompat
@@ -105,6 +104,8 @@ import com.arflix.tv.data.repository.WatchlistRepository
import com.arflix.tv.data.repository.toLauncherContinueWatchingRequest
import com.arflix.tv.navigation.AppNavigation
import com.arflix.tv.navigation.Screen
+import com.arflix.tv.data.db.DownloadStatus
+import com.arflix.tv.ui.screens.downloads.DownloadsViewModel
import com.arflix.tv.ui.screens.login.LoginScreen
import com.arflix.tv.ui.startup.StartupViewModel
import com.arflix.tv.ui.theme.ArflixTvTheme
@@ -575,6 +576,26 @@ fun ArflixApp(
Screen.ProfileSelection.route
}
+ val downloadsViewModel: DownloadsViewModel = hiltViewModel()
+ val downloadsState by downloadsViewModel.uiState.collectAsStateWithLifecycle()
+ val hasAnyDownloads: Boolean by remember {
+ derivedStateOf {
+ downloadsState.movieDownloads.isNotEmpty() ||
+ downloadsState.seriesDownloads.values.any { it.isNotEmpty() }
+ }
+ }
+ val activeDownloadProgress: Float? by remember {
+ derivedStateOf {
+ val all = downloadsState.movieDownloads +
+ downloadsState.seriesDownloads.values.flatten()
+ val active = all.filter {
+ it.status == DownloadStatus.DOWNLOADING.name || it.status == DownloadStatus.QUEUED.name
+ }
+ if (active.isEmpty()) null
+ else active.map { it.progress }.average().toFloat() / 100f
+ }
+ }
+
val deviceType = LocalDeviceType.current
val isMobile = deviceType.isTouchDevice()
val currentBackStackEntry by navController.currentBackStackEntryAsState()
@@ -654,6 +675,8 @@ fun ArflixApp(
launchSingleTop = true
}
},
+ activeDownloadProgress = activeDownloadProgress,
+ hasAnyDownloads = hasAnyDownloads,
modifier = Modifier.fillMaxWidth()
)
}
diff --git a/app/src/main/kotlin/com/arflix/tv/data/db/ArflixDatabase.kt b/app/src/main/kotlin/com/arflix/tv/data/db/ArflixDatabase.kt
new file mode 100644
index 00000000..ea2e4477
--- /dev/null
+++ b/app/src/main/kotlin/com/arflix/tv/data/db/ArflixDatabase.kt
@@ -0,0 +1,17 @@
+package com.arflix.tv.data.db
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+
+@Database(
+ entities = [DownloadEntity::class],
+ version = 1,
+ exportSchema = false
+)
+abstract class ArflixDatabase : RoomDatabase() {
+ abstract fun downloadDao(): DownloadDao
+
+ companion object {
+ const val DATABASE_NAME = "arflix_db"
+ }
+}
diff --git a/app/src/main/kotlin/com/arflix/tv/data/db/DownloadDao.kt b/app/src/main/kotlin/com/arflix/tv/data/db/DownloadDao.kt
new file mode 100644
index 00000000..b9bc7a8c
--- /dev/null
+++ b/app/src/main/kotlin/com/arflix/tv/data/db/DownloadDao.kt
@@ -0,0 +1,64 @@
+package com.arflix.tv.data.db
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface DownloadDao {
+
+ @Query("SELECT * FROM downloads ORDER BY created_at DESC")
+ fun observeAll(): Flow>
+
+ @Query("SELECT * FROM downloads WHERE tmdb_id = :tmdbId ORDER BY season, episode")
+ fun observeByTmdbId(tmdbId: Int): Flow>
+
+ @Query(
+ "SELECT * FROM downloads WHERE tmdb_id = :tmdbId AND media_type = :mediaType " +
+ "AND season IS :season AND episode IS :episode LIMIT 1"
+ )
+ suspend fun findDownload(
+ tmdbId: Int,
+ mediaType: String,
+ season: Int?,
+ episode: Int?
+ ): DownloadEntity?
+
+ @Query("SELECT * FROM downloads WHERE id = :id LIMIT 1")
+ suspend fun getById(id: Long): DownloadEntity?
+
+ @Query("SELECT * FROM downloads WHERE tmdb_id = :tmdbId")
+ suspend fun getAllByTmdbId(tmdbId: Int): List
+
+ @Query("SELECT * FROM downloads WHERE local_uri = :localUri LIMIT 1")
+ suspend fun findByLocalUri(localUri: String): DownloadEntity?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(entity: DownloadEntity): Long
+
+ @Update
+ suspend fun update(entity: DownloadEntity)
+
+ @Delete
+ suspend fun delete(entity: DownloadEntity)
+
+ @Query("UPDATE downloads SET progress = :progress, downloaded_bytes = :downloadedBytes WHERE id = :id")
+ suspend fun updateProgress(id: Long, progress: Int, downloadedBytes: Long)
+
+ @Query(
+ "UPDATE downloads SET status = :status, local_uri = :localUri, file_size = :fileSize WHERE id = :id"
+ )
+ suspend fun markCompleted(id: Long, status: String, localUri: String, fileSize: Long)
+
+ @Query("UPDATE downloads SET status = :status WHERE id = :id")
+ suspend fun updateStatus(id: Long, status: String)
+
+ @Query(
+ "UPDATE downloads SET subtitle_local_uri = :subtitleLocalUri, subtitle_lang = :lang WHERE id = :id"
+ )
+ suspend fun updateSubtitle(id: Long, subtitleLocalUri: String, lang: String)
+}
diff --git a/app/src/main/kotlin/com/arflix/tv/data/db/DownloadEntity.kt b/app/src/main/kotlin/com/arflix/tv/data/db/DownloadEntity.kt
new file mode 100644
index 00000000..a4835da0
--- /dev/null
+++ b/app/src/main/kotlin/com/arflix/tv/data/db/DownloadEntity.kt
@@ -0,0 +1,40 @@
+package com.arflix.tv.data.db
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+enum class DownloadStatus { QUEUED, DOWNLOADING, PAUSED, COMPLETED, FAILED }
+
+@Entity(
+ tableName = "downloads",
+ indices = [Index(value = ["tmdb_id", "media_type", "season", "episode"], unique = true)]
+)
+data class DownloadEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ @ColumnInfo(name = "tmdb_id") val tmdbId: Int,
+ @ColumnInfo(name = "media_type") val mediaType: String,
+ val season: Int? = null,
+ val episode: Int? = null,
+ val title: String,
+ @ColumnInfo(name = "episode_title") val episodeTitle: String? = null,
+ @ColumnInfo(name = "poster_path") val posterPath: String? = null,
+ @ColumnInfo(name = "backdrop_path") val backdropPath: String? = null,
+ @ColumnInfo(name = "local_uri") val localUri: String? = null,
+ @ColumnInfo(name = "stream_url") val streamUrl: String,
+ @ColumnInfo(name = "addon_id") val addonId: String = "",
+ @ColumnInfo(name = "addon_name") val addonName: String = "",
+ val quality: String = "",
+ @ColumnInfo(name = "file_size") val fileSize: Long = 0L,
+ @ColumnInfo(name = "downloaded_bytes") val downloadedBytes: Long = 0L,
+ val status: String = DownloadStatus.QUEUED.name,
+ val progress: Int = 0,
+ @ColumnInfo(name = "worker_id") val workerId: String? = null,
+ @ColumnInfo(name = "subtitle_url") val subtitleUrl: String? = null,
+ @ColumnInfo(name = "subtitle_local_uri") val subtitleLocalUri: String? = null,
+ @ColumnInfo(name = "subtitle_lang") val subtitleLang: String? = null,
+ @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
+ // JSON-serialized Map of stream-specific request headers (Referer, Authorization, etc.)
+ @ColumnInfo(name = "headers") val headers: String? = null
+)
diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/DownloadsRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/DownloadsRepository.kt
new file mode 100644
index 00000000..1ba971b2
--- /dev/null
+++ b/app/src/main/kotlin/com/arflix/tv/data/repository/DownloadsRepository.kt
@@ -0,0 +1,201 @@
+package com.arflix.tv.data.repository
+
+import android.content.Context
+import androidx.work.BackoffPolicy
+import androidx.work.Constraints
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.workDataOf
+import com.arflix.tv.data.db.DownloadDao
+import com.arflix.tv.data.db.DownloadEntity
+import com.arflix.tv.data.db.DownloadStatus
+import com.arflix.tv.network.OkHttpProvider
+import com.arflix.tv.worker.DownloadWorker
+import com.google.gson.Gson
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import java.io.File
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class DownloadsRepository @Inject constructor(
+ private val dao: DownloadDao,
+ private val workManager: WorkManager,
+ @ApplicationContext private val context: Context
+) {
+
+ fun observeAllDownloads(): Flow> = dao.observeAll()
+
+ fun observeDownloadsForMedia(tmdbId: Int): Flow> =
+ dao.observeByTmdbId(tmdbId)
+
+ suspend fun getDownloadForEpisode(
+ tmdbId: Int,
+ mediaType: String,
+ season: Int?,
+ episode: Int?
+ ): DownloadEntity? = dao.findDownload(tmdbId, mediaType, season, episode)
+
+ suspend fun getDownloadByLocalUri(localUri: String): DownloadEntity? =
+ dao.findByLocalUri(localUri)
+
+ suspend fun enqueueDownload(
+ tmdbId: Int,
+ mediaType: String,
+ season: Int?,
+ episode: Int?,
+ title: String,
+ episodeTitle: String?,
+ posterPath: String?,
+ backdropPath: String?,
+ streamUrl: String,
+ addonId: String,
+ addonName: String,
+ quality: String,
+ subtitleUrl: String?,
+ subtitleLang: String?,
+ headers: Map? = null
+ ) {
+ val existing = dao.findDownload(tmdbId, mediaType, season, episode)
+ if (existing != null) {
+ when (existing.status) {
+ DownloadStatus.COMPLETED.name -> return
+ DownloadStatus.QUEUED.name,
+ DownloadStatus.DOWNLOADING.name -> return
+ DownloadStatus.FAILED.name -> {
+ retryDownload(existing.id)
+ return
+ }
+ DownloadStatus.PAUSED.name -> {
+ resumeDownload(existing.id)
+ return
+ }
+ }
+ }
+
+ val headersJson = headers?.takeIf { it.isNotEmpty() }?.let { Gson().toJson(it) }
+ val entity = DownloadEntity(
+ tmdbId = tmdbId,
+ mediaType = mediaType,
+ season = season,
+ episode = episode,
+ title = title,
+ episodeTitle = episodeTitle,
+ posterPath = posterPath,
+ backdropPath = backdropPath,
+ streamUrl = streamUrl,
+ addonId = addonId,
+ addonName = addonName,
+ quality = quality,
+ subtitleUrl = subtitleUrl,
+ subtitleLang = subtitleLang,
+ status = DownloadStatus.QUEUED.name,
+ headers = headersJson
+ )
+ val id = dao.insert(entity)
+ scheduleWork(id, streamUrl, subtitleUrl)
+ }
+
+ suspend fun pauseDownload(id: Long) {
+ workManager.cancelAllWorkByTag(workTag(id))
+ dao.updateStatus(id, DownloadStatus.PAUSED.name)
+ // Keep the partial file — resume will append from where we left off via Range header.
+ }
+
+ suspend fun resumeDownload(id: Long) {
+ val entity = dao.getById(id) ?: return
+ dao.updateStatus(id, DownloadStatus.QUEUED.name)
+ scheduleWork(id, entity.streamUrl, entity.subtitleUrl)
+ }
+
+ suspend fun cancelDownload(id: Long) {
+ val entity = dao.getById(id) ?: return
+ workManager.cancelAllWorkByTag(workTag(id))
+ entity.localUri?.let { File(it) }?.takeIf { it.exists() }?.delete()
+ dao.updateStatus(id, DownloadStatus.FAILED.name)
+ }
+
+ suspend fun deleteDownload(id: Long) {
+ val entity = dao.getById(id) ?: return
+ workManager.cancelAllWorkByTag(workTag(id))
+ entity.localUri?.let { File(it) }?.takeIf { it.exists() }?.delete()
+ entity.subtitleLocalUri?.let { File(it) }?.takeIf { it.exists() }?.delete()
+ dao.delete(entity)
+ }
+
+ suspend fun retryDownload(id: Long) {
+ val entity = dao.getById(id) ?: return
+ dao.updateStatus(id, DownloadStatus.QUEUED.name)
+ scheduleWork(id, entity.streamUrl, entity.subtitleUrl)
+ }
+
+ suspend fun deleteAllForSeries(tmdbId: Int) {
+ dao.getAllByTmdbId(tmdbId).forEach { deleteDownload(it.id) }
+ }
+
+ fun downloadsDir(): File =
+ context.getExternalFilesDir("downloads") ?: context.filesDir.resolve("downloads")
+
+ suspend fun updateProgressInternal(id: Long, progress: Int, downloadedBytes: Long) {
+ dao.updateProgress(id, progress, downloadedBytes)
+ }
+
+ suspend fun markCompletedInternal(
+ id: Long,
+ localUri: String,
+ fileSize: Long,
+ subtitleLocalUri: String?,
+ subtitleLang: String?
+ ) {
+ dao.markCompleted(id, DownloadStatus.COMPLETED.name, localUri, fileSize)
+ if (subtitleLocalUri != null && subtitleLang != null) {
+ dao.updateSubtitle(id, subtitleLocalUri, subtitleLang)
+ }
+ }
+
+ suspend fun markFailedInternal(id: Long) {
+ dao.updateStatus(id, DownloadStatus.FAILED.name)
+ }
+
+ suspend fun updateStatusInternal(id: Long, status: DownloadStatus) {
+ dao.updateStatus(id, status.name)
+ }
+
+ private suspend fun scheduleWork(id: Long, streamUrl: String, subtitleUrl: String?) {
+ val entity = dao.getById(id)
+ val request = OneTimeWorkRequestBuilder()
+ .addTag(workTag(id))
+ .setConstraints(
+ Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+ )
+ .setInputData(
+ workDataOf(
+ DownloadWorker.KEY_DOWNLOAD_ID to id,
+ DownloadWorker.KEY_STREAM_URL to streamUrl,
+ DownloadWorker.KEY_SUBTITLE_URL to subtitleUrl,
+ DownloadWorker.KEY_USER_AGENT to OkHttpProvider.userAgent,
+ DownloadWorker.KEY_HEADERS to (entity?.headers ?: ""),
+ DownloadWorker.KEY_TITLE to (entity?.title ?: ""),
+ DownloadWorker.KEY_SEASON to (entity?.season ?: -1),
+ DownloadWorker.KEY_EPISODE to (entity?.episode ?: -1)
+ )
+ )
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
+ .build()
+
+ workManager.enqueueUniqueWork(
+ workName(id),
+ ExistingWorkPolicy.KEEP,
+ request
+ )
+ }
+
+ private fun workTag(id: Long) = "download_$id"
+ private fun workName(id: Long) = "download_$id"
+}
diff --git a/app/src/main/kotlin/com/arflix/tv/di/DatabaseModule.kt b/app/src/main/kotlin/com/arflix/tv/di/DatabaseModule.kt
new file mode 100644
index 00000000..55d8b3c1
--- /dev/null
+++ b/app/src/main/kotlin/com/arflix/tv/di/DatabaseModule.kt
@@ -0,0 +1,33 @@
+package com.arflix.tv.di
+
+import android.content.Context
+import androidx.room.Room
+import androidx.work.WorkManager
+import com.arflix.tv.data.db.ArflixDatabase
+import com.arflix.tv.data.db.DownloadDao
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DatabaseModule {
+
+ @Provides
+ @Singleton
+ fun provideDatabase(@ApplicationContext context: Context): ArflixDatabase =
+ Room.databaseBuilder(context, ArflixDatabase::class.java, ArflixDatabase.DATABASE_NAME)
+ .fallbackToDestructiveMigration()
+ .build()
+
+ @Provides
+ fun provideDownloadDao(db: ArflixDatabase): DownloadDao = db.downloadDao()
+
+ @Provides
+ @Singleton
+ fun provideWorkManager(@ApplicationContext context: Context): WorkManager =
+ WorkManager.getInstance(context)
+}
diff --git a/app/src/main/kotlin/com/arflix/tv/di/RepositoryAccessEntryPoint.kt b/app/src/main/kotlin/com/arflix/tv/di/RepositoryAccessEntryPoint.kt
index 2e7fde82..c6a06401 100644
--- a/app/src/main/kotlin/com/arflix/tv/di/RepositoryAccessEntryPoint.kt
+++ b/app/src/main/kotlin/com/arflix/tv/di/RepositoryAccessEntryPoint.kt
@@ -2,6 +2,7 @@ package com.arflix.tv.di
import com.arflix.tv.data.api.TmdbApi
import com.arflix.tv.data.repository.CloudSyncInvalidationBus
+import com.arflix.tv.data.repository.DownloadsRepository
import com.arflix.tv.data.repository.MediaRepository
import com.arflix.tv.data.repository.ProfileManager
import com.arflix.tv.data.repository.ProfileRepository
@@ -19,4 +20,5 @@ interface RepositoryAccessEntryPoint {
fun profileManager(): ProfileManager
fun cloudSyncInvalidationBus(): CloudSyncInvalidationBus
fun tmdbApi(): TmdbApi
+ fun downloadsRepository(): DownloadsRepository
}
diff --git a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt
index c74a37bb..2f8f49e7 100644
--- a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt
+++ b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt
@@ -15,6 +15,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
+import com.arflix.tv.data.db.DownloadEntity
import com.arflix.tv.data.model.Category
import com.arflix.tv.data.model.MediaItem
import com.arflix.tv.data.model.MediaType
@@ -29,6 +30,7 @@ import com.arflix.tv.ui.screens.search.SearchScreen
import com.arflix.tv.ui.screens.settings.SettingsScreen
import com.arflix.tv.ui.screens.settings.telegram.TelegramSettingsScreen
import com.arflix.tv.ui.screens.tv.live.LiveTvScreen
+import com.arflix.tv.ui.screens.downloads.DownloadedEpisodesScreen
import com.arflix.tv.ui.screens.watchlist.WatchlistScreen
import com.arflix.tv.ui.screens.profile.ProfileSelectionScreen
import com.arflix.tv.util.LocalDeviceType
@@ -40,7 +42,9 @@ sealed class Screen(val route: String) {
object Login : Screen("login")
object Home : Screen("home")
object Search : Screen("search")
- object Watchlist : Screen("watchlist")
+ object Watchlist : Screen("watchlist") {
+ fun downloadsRoute() = "watchlist?tab=1"
+ }
object CollectionDetails : Screen("collections/{catalogId}") {
fun createRoute(catalogId: String): String {
return "collections/${android.net.Uri.encode(catalogId)}"
@@ -58,6 +62,11 @@ sealed class Screen(val route: String) {
object TelegramSettings : Screen("telegram_settings")
object ProfileSelection : Screen("profile_selection")
+ object DownloadedEpisodes : Screen("downloaded_episodes/{tmdbId}?title={title}") {
+ fun createRoute(tmdbId: Int, title: String) =
+ "downloaded_episodes/$tmdbId?title=${android.net.Uri.encode(title)}"
+ }
+
object Details : Screen("details/{mediaType}/{mediaId}?initialSeason={initialSeason}&initialEpisode={initialEpisode}") {
fun createRoute(
mediaType: MediaType,
@@ -218,9 +227,14 @@ fun AppNavigation(
)
}
- // Watchlist screen
- composable(Screen.Watchlist.route) {
+ // Watchlist screen (optional ?tab=1 opens Downloads tab directly)
+ composable(
+ route = "watchlist?tab={tab}",
+ arguments = listOf(navArgument("tab") { type = NavType.IntType; defaultValue = 0 })
+ ) { backStackEntry ->
+ val initialTab = backStackEntry.arguments?.getInt("tab") ?: 0
WatchlistScreen(
+ initialTab = initialTab,
currentProfile = currentProfile,
onNavigateToDetails = { mediaType, mediaId ->
navController.navigate(Screen.Details.createRoute(mediaType, mediaId))
@@ -229,6 +243,23 @@ fun AppNavigation(
onNavigateToSearch = { navigateTopLevel(Screen.Search.route) },
onNavigateToTv = { navigateTopLevel(Screen.Tv.createRoute()) },
onNavigateToSettings = { navigateTopLevel(Screen.Settings.route) },
+ onNavigateToDownloadedEpisodes = { tmdbId, title ->
+ navController.navigate(Screen.DownloadedEpisodes.createRoute(tmdbId, title))
+ },
+ onNavigateToPlayer = { download ->
+ val mediaType = runCatching {
+ MediaType.valueOf(download.mediaType.uppercase())
+ }.getOrDefault(MediaType.MOVIE)
+ navController.navigate(
+ Screen.Player.createRoute(
+ mediaType = mediaType,
+ mediaId = download.tmdbId,
+ seasonNumber = download.season,
+ episodeNumber = download.episode,
+ streamUrl = download.localUri?.let { "file://$it" }
+ )
+ )
+ },
onSwitchProfile = {
onSwitchProfile()
navController.navigate(Screen.ProfileSelection.route) {
@@ -239,6 +270,41 @@ fun AppNavigation(
)
}
+ // Downloaded episodes for a TV series
+ composable(
+ route = Screen.DownloadedEpisodes.route,
+ arguments = listOf(
+ navArgument("tmdbId") { type = NavType.IntType },
+ navArgument("title") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = ""
+ }
+ )
+ ) { backStackEntry ->
+ val tmdbId = backStackEntry.arguments?.getInt("tmdbId") ?: return@composable
+ val title = android.net.Uri.decode(backStackEntry.arguments?.getString("title") ?: "")
+ DownloadedEpisodesScreen(
+ tmdbId = tmdbId,
+ title = title,
+ onPlayEpisode = { download ->
+ val mediaType = runCatching {
+ MediaType.valueOf(download.mediaType.uppercase())
+ }.getOrDefault(MediaType.TV)
+ navController.navigate(
+ Screen.Player.createRoute(
+ mediaType = mediaType,
+ mediaId = download.tmdbId,
+ seasonNumber = download.season,
+ episodeNumber = download.episode,
+ streamUrl = download.localUri?.let { "file://$it" }
+ )
+ )
+ },
+ onBack = { navController.popBackStack() }
+ )
+ }
+
// TV screen
composable(
route = Screen.Tv.route,
diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt
index a0e8a871..49025b41 100644
--- a/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt
+++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt
@@ -18,10 +18,12 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
+import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -70,6 +72,8 @@ val bottomBarItems = listOf(
fun AppBottomBar(
currentRoute: String?,
onNavigate: (String) -> Unit,
+ activeDownloadProgress: Float? = null,
+ hasAnyDownloads: Boolean = false,
modifier: Modifier = Modifier
) {
Column(modifier = modifier.fillMaxWidth()) {
@@ -89,9 +93,21 @@ fun AppBottomBar(
verticalAlignment = Alignment.CenterVertically
) {
bottomBarItems.forEach { item ->
+ val isWatchlist = item.route == "watchlist"
+ val showAsDownloads = isWatchlist && hasAnyDownloads
+ val isDownloading = showAsDownloads && activeDownloadProgress != null
+ val navRoute = if (showAsDownloads) "watchlist?tab=1" else item.route
val isSelected = currentRoute?.contains(item.route, ignoreCase = true) == true
var isFocused by remember { mutableStateOf(false) }
- val label = stringResource(item.labelRes)
+ val label = when {
+ showAsDownloads -> "Downloads"
+ else -> stringResource(item.labelRes)
+ }
+ val iconTint = when {
+ isFocused -> Color.White
+ isSelected -> TextPrimary
+ else -> TextSecondary.copy(alpha = 0.6f)
+ }
Column(
modifier = Modifier
@@ -106,11 +122,11 @@ fun AppBottomBar(
.onFocusChanged { isFocused = it.isFocused }
.onKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && (event.key == Key.Enter || event.key == Key.DirectionCenter)) {
- onNavigate(item.route)
+ onNavigate(navRoute)
true
} else false
}
- .clickable { onNavigate(item.route) }
+ .clickable { onNavigate(navRoute) }
.padding(vertical = 2.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp)
@@ -128,16 +144,46 @@ fun AppBottomBar(
.padding(horizontal = 14.dp, vertical = 4.dp),
contentAlignment = Alignment.Center
) {
- Icon(
- imageVector = item.icon,
- contentDescription = label,
- tint = when {
- isFocused -> Color.White
- isSelected -> TextPrimary
- else -> TextSecondary.copy(alpha = 0.6f)
- },
- modifier = Modifier.size(24.dp)
- )
+ if (isDownloading) {
+ Box(contentAlignment = Alignment.Center) {
+ if (activeDownloadProgress!! > 0f) {
+ CircularProgressIndicator(
+ progress = { activeDownloadProgress },
+ modifier = Modifier.size(32.dp),
+ color = iconTint,
+ strokeWidth = 2.dp,
+ trackColor = Color.White.copy(alpha = 0.15f)
+ )
+ } else {
+ CircularProgressIndicator(
+ modifier = Modifier.size(32.dp),
+ color = iconTint,
+ strokeWidth = 2.dp,
+ trackColor = Color.White.copy(alpha = 0.15f)
+ )
+ }
+ Icon(
+ imageVector = Icons.Default.Download,
+ contentDescription = label,
+ tint = iconTint,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ } else if (showAsDownloads) {
+ Icon(
+ imageVector = Icons.Default.Download,
+ contentDescription = label,
+ tint = iconTint,
+ modifier = Modifier.size(24.dp)
+ )
+ } else {
+ Icon(
+ imageVector = item.icon,
+ contentDescription = label,
+ tint = iconTint,
+ modifier = Modifier.size(24.dp)
+ )
+ }
}
if (isSelected) {
Box(
diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt
index 79affff9..0e0b9d6f 100644
--- a/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt
+++ b/app/src/main/kotlin/com/arflix/tv/ui/components/ContextMenu.kt
@@ -29,6 +29,8 @@ import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Visibility
@@ -86,6 +88,8 @@ object ContextActions {
val viewDetails = ContextAction("view_details", "View Details", Icons.Default.Info, TextPrimary)
val markSeasonWatched = ContextAction("mark_season_watched", "Mark Season Watched", Icons.Default.Check, Color(0xFF22C55E))
val markSeasonUnwatched = ContextAction("mark_season_unwatched", "Mark Season Unwatched", Icons.Default.Clear, TextSecondary)
+ val download = ContextAction("download", "Download", Icons.Default.Download, Pink)
+ val removeDownload = ContextAction("remove_download", "Remove Download", Icons.Default.Delete, Color(0xFFEF4444))
}
/**
@@ -422,13 +426,19 @@ fun EpisodeContextMenu(
onPlay: () -> Unit,
onSelectSource: () -> Unit,
onToggleWatched: () -> Unit,
- onDismiss: () -> Unit
+ onDismiss: () -> Unit,
+ isDownloaded: Boolean = false,
+ onDownload: (() -> Unit)? = null,
+ onRemoveDownload: (() -> Unit)? = null
) {
- val actions = listOf(
- ContextActions.play,
- ContextActions.selectSource,
- if (isWatched) ContextActions.markUnwatched else ContextActions.markWatched
- )
+ val isMobile = LocalDeviceType.current.isTouchDevice()
+ val actions = buildList {
+ add(ContextActions.play)
+ add(ContextActions.selectSource)
+ add(if (isWatched) ContextActions.markUnwatched else ContextActions.markWatched)
+ if (isMobile && onDownload != null && !isDownloaded) add(ContextActions.download)
+ if (isMobile && onRemoveDownload != null && isDownloaded) add(ContextActions.removeDownload)
+ }
ContextMenu(
isVisible = isVisible,
@@ -440,6 +450,8 @@ fun EpisodeContextMenu(
"play" -> onPlay()
"sources" -> onSelectSource()
"mark_watched", "mark_unwatched" -> onToggleWatched()
+ "download" -> onDownload?.invoke()
+ "remove_download" -> onRemoveDownload?.invoke()
}
onDismiss()
},
diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/DownloadActionsSheet.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/DownloadActionsSheet.kt
new file mode 100644
index 00000000..36a3b777
--- /dev/null
+++ b/app/src/main/kotlin/com/arflix/tv/ui/components/DownloadActionsSheet.kt
@@ -0,0 +1,221 @@
+package com.arflix.tv.ui.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Cancel
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Text
+import com.arflix.tv.data.db.DownloadEntity
+import com.arflix.tv.data.db.DownloadStatus
+import com.arflix.tv.ui.theme.ArflixTypography
+import com.arflix.tv.ui.theme.TextSecondary
+import com.arflix.tv.util.formatBytes
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun DownloadActionsSheet(
+ download: DownloadEntity?,
+ onDismiss: () -> Unit,
+ onPause: (Long) -> Unit,
+ onResume: (Long) -> Unit,
+ onCancel: (Long) -> Unit,
+ onDelete: (Long) -> Unit,
+ onRetry: (Long) -> Unit
+) {
+ val isVisible = download != null
+ val status = download?.let {
+ DownloadStatus.entries.find { e -> e.name == it.status } ?: DownloadStatus.QUEUED
+ }
+
+ AnimatedVisibility(
+ visible = isVisible,
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.6f))
+ .clickable(onClick = onDismiss)
+ .zIndex(20f)
+ )
+ }
+
+ AnimatedVisibility(
+ visible = isVisible,
+ enter = slideInVertically { it } + fadeIn(),
+ exit = slideOutVertically { it } + fadeOut()
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize().zIndex(21f),
+ contentAlignment = Alignment.BottomCenter
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
+ .background(Color(0xFF1A1A1A))
+ .navigationBarsPadding()
+ .padding(bottom = 8.dp)
+ ) {
+ // Handle
+ Box(
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .padding(top = 12.dp, bottom = 8.dp)
+ .size(width = 40.dp, height = 4.dp)
+ .background(Color.White.copy(alpha = 0.3f), RoundedCornerShape(2.dp))
+ )
+
+ // Header
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ val titleLine = download?.title.orEmpty()
+ val subtitleLine = buildString {
+ download?.season?.let { s ->
+ download.episode?.let { e ->
+ append("S${s.toString().padStart(2, '0')}E${e.toString().padStart(2, '0')}")
+ download.episodeTitle?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
+ }
+ }
+ if (isEmpty()) {
+ when (status) {
+ DownloadStatus.COMPLETED -> download?.fileSize?.let { sz ->
+ if (sz > 0) append(formatBytes(sz))
+ }
+ DownloadStatus.DOWNLOADING -> append("${download?.progress ?: 0}%")
+ DownloadStatus.PAUSED -> append("Paused · ${download?.progress ?: 0}%")
+ else -> {}
+ }
+ }
+ }
+ Text(
+ text = titleLine,
+ style = ArflixTypography.sectionTitle,
+ color = Color.White,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (subtitleLine.isNotBlank()) {
+ Text(
+ text = subtitleLine,
+ style = ArflixTypography.caption,
+ color = TextSecondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ IconButton(onClick = onDismiss) {
+ Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White)
+ }
+ }
+
+ HorizontalDivider(color = Color.White.copy(alpha = 0.1f))
+
+ if (download != null) {
+ when (status!!) {
+ DownloadStatus.DOWNLOADING -> {
+ ActionRow(Icons.Default.Pause, "Pause") {
+ onDismiss(); onPause(download.id)
+ }
+ ActionRow(Icons.Default.Cancel, "Cancel download") {
+ onDismiss(); onCancel(download.id)
+ }
+ }
+ DownloadStatus.PAUSED -> {
+ ActionRow(Icons.Default.PlayArrow, "Resume") {
+ onDismiss(); onResume(download.id)
+ }
+ ActionRow(Icons.Default.Cancel, "Cancel download") {
+ onDismiss(); onCancel(download.id)
+ }
+ }
+ DownloadStatus.FAILED, DownloadStatus.QUEUED -> {
+ ActionRow(Icons.Default.Refresh, "Retry") {
+ onDismiss(); onRetry(download.id)
+ }
+ }
+ DownloadStatus.COMPLETED -> Unit
+ }
+ ActionRow(
+ icon = Icons.Default.Delete,
+ label = "Delete download",
+ tint = Color(0xFFFF5252)
+ ) {
+ onDismiss(); onDelete(download.id)
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun ActionRow(
+ icon: ImageVector,
+ label: String,
+ tint: Color = Color.White,
+ onClick: () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = tint,
+ modifier = Modifier.size(22.dp)
+ )
+ Text(
+ text = label,
+ style = ArflixTypography.body,
+ color = tint,
+ modifier = Modifier.padding(start = 16.dp)
+ )
+ }
+ HorizontalDivider(color = Color.White.copy(alpha = 0.07f))
+}
+
diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/DownloadSheet.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/DownloadSheet.kt
new file mode 100644
index 00000000..7cef28c7
--- /dev/null
+++ b/app/src/main/kotlin/com/arflix/tv/ui/components/DownloadSheet.kt
@@ -0,0 +1,505 @@
+package com.arflix.tv.ui.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import com.arflix.tv.network.OkHttpProvider
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.Request
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.zIndex
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Text
+import com.arflix.tv.data.model.StreamSource
+import com.arflix.tv.data.model.Subtitle
+import com.arflix.tv.data.repository.HomeServerRepository
+import com.arflix.tv.ui.theme.ArflixTypography
+import com.arflix.tv.ui.theme.TextSecondary
+import com.arflix.tv.util.filterSubtitlesByLanguage
+import com.arflix.tv.util.isSubtitleLangDisabled
+import com.arflix.tv.util.normalizeSubtitleLang
+import com.arflix.tv.util.subtitleMatchesLanguage
+
+private fun StreamSource.isDirectDownloadable(): Boolean {
+ val u = url ?: return false
+ if (u.startsWith("magnet:", ignoreCase = true)) return false
+ if (u.contains(".m3u8", ignoreCase = true)) return false
+ if (u.contains(".mpd", ignoreCase = true)) return false
+ return u.startsWith("http://", ignoreCase = true) || u.startsWith("https://", ignoreCase = true)
+}
+
+// Play Store policy: restrict downloads to user-controlled local servers only.
+// To expand to other sources, remove the isLocalServerStream() condition here.
+private fun StreamSource.isLocalServerStream(): Boolean =
+ addonId == HomeServerRepository.ADDON_ID
+
+private fun StreamSource.displayTitle(): String =
+ behaviorHints?.filename?.takeIf { it.isNotBlank() } ?: source
+
+private fun StreamSource.addonLabel(): String =
+ addonName.split(" - ").firstOrNull()?.trim() ?: addonName
+
+/** Merges stream-bundled subtitles with externally-fetched ones, deduped by URL. */
+private fun mergeSubtitles(streamSubs: List, externalSubs: List): List {
+ val seen = mutableSetOf()
+ return (streamSubs + externalSubs)
+ .filter { !it.isEmbedded && it.url.isNotBlank() }
+ .filter { seen.add(it.url) }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun DownloadSheet(
+ isVisible: Boolean,
+ title: String,
+ streams: List,
+ subtitles: List = emptyList(),
+ isLoadingStreams: Boolean,
+ isLoadingSubtitles: Boolean = false,
+ preferredSubtitleLang: String = "",
+ secondarySubtitleLang: String = "",
+ onConfirm: (stream: StreamSource, subtitle: Subtitle?) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val downloadable = streams.filter { it.isDirectDownloadable() && it.isLocalServerStream() }
+ var selectedStream by remember(isVisible) { mutableStateOf(null) }
+
+ // Auto-select the first subtitle matching preferred language when a stream is picked
+ var selectedSubtitle by remember(selectedStream) {
+ val stream = selectedStream
+ val allSubs = if (stream != null) mergeSubtitles(stream.subtitles, subtitles) else emptyList()
+ val preferredNorm = normalizeSubtitleLang(preferredSubtitleLang)
+ val autoSelect = if (!isSubtitleLangDisabled(preferredSubtitleLang))
+ allSubs.firstOrNull { subtitleMatchesLanguage(it, preferredNorm) }
+ else null
+ mutableStateOf(autoSelect)
+ }
+ var resolvedSizeBytes by remember(selectedStream) { mutableStateOf(null) }
+ // true = HEAD response set cookies the worker's cookie-less client can't maintain
+ var sessionCookieDetected by remember(selectedStream) { mutableStateOf(false) }
+
+ LaunchedEffect(selectedStream) {
+ val stream = selectedStream ?: return@LaunchedEffect
+ val url = stream.url ?: return@LaunchedEffect
+ val needsSize = stream.size.isBlank() && stream.sizeBytes == null
+ if (!needsSize) return@LaunchedEffect
+ runCatching {
+ withContext(Dispatchers.IO) {
+ val streamRequestHeaders = stream.behaviorHints?.proxyHeaders?.request.orEmpty()
+ fun Request.Builder.applyStreamHeaders() = apply {
+ header("User-Agent", OkHttpProvider.userAgent)
+ streamRequestHeaders.forEach { (k, v) -> if (k.isNotBlank()) header(k, v) }
+ }
+
+ // 1. Try HEAD — fast and zero-bandwidth
+ val headResp = runCatching {
+ OkHttpProvider.playbackClient.newCall(
+ Request.Builder().url(url).head().applyStreamHeaders().build()
+ ).execute()
+ }.getOrNull()
+
+ val headContentLength = headResp?.use { resp ->
+ sessionCookieDetected = resp.headers("Set-Cookie").isNotEmpty()
+ if (resp.isSuccessful) resp.header("Content-Length")?.toLongOrNull()
+ ?.takeIf { it > 0L } else null
+ }
+
+ if (headContentLength != null) {
+ resolvedSizeBytes = headContentLength
+ return@withContext
+ }
+
+ // 2. HEAD missing/zero Content-Length (common for IPTV/Xtream Codes panels).
+ // Range GET bytes=0-0: server replies 206 with Content-Range: bytes 0-0/
+ // giving the full file size. If server ignores Range and returns 200, we
+ // read Content-Length from the response body length instead.
+ val rangeResp = runCatching {
+ OkHttpProvider.playbackClient.newCall(
+ Request.Builder().url(url).get().applyStreamHeaders()
+ .header("Range", "bytes=0-0").build()
+ ).execute()
+ }.getOrNull()
+
+ rangeResp?.use { resp ->
+ if (!sessionCookieDetected) {
+ sessionCookieDetected = resp.headers("Set-Cookie").isNotEmpty()
+ }
+ resolvedSizeBytes = when {
+ // 206: Content-Range: bytes 0-0/ — total after the last '/'
+ resp.code == 206 -> resp.header("Content-Range")
+ ?.substringAfterLast('/')?.trimEnd()?.toLongOrNull()
+ ?.takeIf { it > 0L }
+ // 200: server ignored Range; Content-Length is the full file size
+ resp.isSuccessful -> resp.body?.contentLength()?.takeIf { it > 0L }
+ ?: resp.header("Content-Length")?.toLongOrNull()?.takeIf { it > 0L }
+ else -> null
+ }
+ }
+ }
+ }
+ }
+
+ AnimatedVisibility(
+ visible = isVisible,
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.6f))
+ .clickable(onClick = onDismiss)
+ .zIndex(20f)
+ )
+ }
+
+ AnimatedVisibility(
+ visible = isVisible,
+ enter = slideInVertically { it } + fadeIn(),
+ exit = slideOutVertically { it } + fadeOut()
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize().zIndex(21f),
+ contentAlignment = Alignment.BottomCenter
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
+ .background(Color(0xFF1A1A1A))
+ .navigationBarsPadding()
+ .padding(bottom = 16.dp)
+ ) {
+ // Handle
+ Box(
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .padding(top = 12.dp, bottom = 8.dp)
+ .size(width = 40.dp, height = 4.dp)
+ .background(Color.White.copy(alpha = 0.3f), RoundedCornerShape(2.dp))
+ )
+
+ // Header
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = if (selectedStream == null) "Download" else "Choose subtitles",
+ style = ArflixTypography.sectionTitle,
+ color = Color.White
+ )
+ Text(
+ text = title,
+ style = ArflixTypography.caption,
+ color = TextSecondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ IconButton(onClick = {
+ if (selectedStream != null) selectedStream = null else onDismiss()
+ }) {
+ Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White)
+ }
+ }
+
+ HorizontalDivider(color = Color.White.copy(alpha = 0.1f))
+
+ when {
+ isLoadingStreams -> {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(160.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(color = Color.White, modifier = Modifier.size(32.dp))
+ }
+ }
+ downloadable.isEmpty() -> {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(160.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "Downloads are only available for your local server (Jellyfin, Emby, or Plex)",
+ style = ArflixTypography.body,
+ color = TextSecondary
+ )
+ }
+ }
+ selectedStream == null -> {
+ Text(
+ text = "Choose a source",
+ style = ArflixTypography.label,
+ color = TextSecondary,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+ LazyColumn(modifier = Modifier.weight(1f, fill = false)) {
+ items(downloadable) { stream ->
+ StreamDownloadRow(
+ stream = stream,
+ onClick = { selectedStream = stream }
+ )
+ }
+ }
+ }
+ else -> {
+ val stream = selectedStream!!
+ val allSubtitles = filterSubtitlesByLanguage(
+ mergeSubtitles(stream.subtitles, subtitles),
+ preferredSubtitleLang,
+ secondarySubtitleLang
+ )
+
+ // Subtitle count / loading hint
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = when {
+ isLoadingSubtitles -> "Loading subtitles…"
+ allSubtitles.isEmpty() -> "No subtitles found"
+ else -> "${allSubtitles.size} subtitle tracks available"
+ },
+ style = ArflixTypography.label,
+ color = TextSecondary
+ )
+ if (isLoadingSubtitles) {
+ CircularProgressIndicator(
+ color = Color.White,
+ modifier = Modifier.size(14.dp),
+ strokeWidth = 2.dp
+ )
+ }
+ }
+
+ LazyColumn(modifier = Modifier.weight(1f, fill = false)) {
+ item {
+ SubtitleRow(
+ label = "No subtitles",
+ isSelected = selectedSubtitle == null,
+ onClick = { selectedSubtitle = null }
+ )
+ }
+ items(allSubtitles) { sub ->
+ SubtitleRow(
+ label = sub.label.ifBlank { sub.lang }.ifBlank { sub.url.substringAfterLast('/').take(30) },
+ isSelected = selectedSubtitle == sub,
+ onClick = { selectedSubtitle = sub }
+ )
+ }
+ }
+
+ val sizeLabel = stream.size.takeIf { it.isNotBlank() }
+ ?: (stream.sizeBytes ?: resolvedSizeBytes)?.let { bytes ->
+ when {
+ bytes >= 1_000_000_000 -> "%.1f GB".format(bytes / 1_000_000_000.0)
+ bytes >= 1_000_000 -> "%.0f MB".format(bytes / 1_000_000.0)
+ else -> null
+ }
+ }
+
+ if (sessionCookieDetected) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(Color.White.copy(alpha = 0.12f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "Not available for download",
+ style = ArflixTypography.button,
+ color = Color.White.copy(alpha = 0.4f)
+ )
+ }
+ Text(
+ text = "This source uses session cookies that can't be saved offline.",
+ style = ArflixTypography.caption,
+ color = TextSecondary,
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center
+ )
+ }
+ } else {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .height(56.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(Color.White)
+ .clickable { onConfirm(stream, selectedSubtitle) },
+ contentAlignment = Alignment.Center
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(Icons.Default.Download, contentDescription = null, tint = Color.Black)
+ Text(
+ text = if (sizeLabel != null) "Download ($sizeLabel)" else "Download",
+ style = ArflixTypography.button,
+ color = Color.Black
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun StreamDownloadRow(stream: StreamSource, onClick: () -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stream.displayTitle(),
+ style = ArflixTypography.body.copy(fontWeight = FontWeight.SemiBold),
+ color = Color.White,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ Spacer(Modifier.height(4.dp))
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stream.addonLabel(),
+ style = ArflixTypography.caption,
+ color = TextSecondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (stream.quality.isNotBlank()) {
+ QualityChip(stream.quality)
+ }
+ if (stream.size.isNotBlank()) {
+ Text(
+ text = stream.size,
+ style = ArflixTypography.caption,
+ color = TextSecondary
+ )
+ }
+ }
+ }
+ Icon(
+ Icons.Default.Download,
+ contentDescription = "Download",
+ tint = Color.White,
+ modifier = Modifier
+ .padding(start = 12.dp)
+ .size(20.dp)
+ )
+ }
+ HorizontalDivider(color = Color.White.copy(alpha = 0.07f))
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun QualityChip(quality: String) {
+ Box(
+ modifier = Modifier
+ .background(Color.White.copy(alpha = 0.1f), RoundedCornerShape(4.dp))
+ .padding(horizontal = 6.dp, vertical = 2.dp)
+ ) {
+ Text(
+ text = quality,
+ style = ArflixTypography.caption,
+ color = Color.White,
+ fontSize = 10.sp
+ )
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun SubtitleRow(label: String, isSelected: Boolean, onClick: () -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = label,
+ style = ArflixTypography.body,
+ color = if (isSelected) Color.White else TextSecondary
+ )
+ if (isSelected) {
+ Icon(Icons.Default.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(18.dp))
+ }
+ }
+ HorizontalDivider(color = Color.White.copy(alpha = 0.07f))
+}
diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt
index 33b02e9b..6acbf907 100644
--- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt
+++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt
@@ -56,6 +56,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.PlayArrow
@@ -63,6 +65,7 @@ import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Icon
+import androidx.compose.ui.window.Dialog
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -134,6 +137,7 @@ import com.arflix.tv.data.model.MediaItem
import com.arflix.tv.data.model.MediaType
import com.arflix.tv.data.model.Review
import com.arflix.tv.network.OkHttpProvider
+import com.arflix.tv.ui.components.DownloadSheet
import com.arflix.tv.ui.components.EpisodeContextMenu
import com.arflix.tv.ui.components.KeepScreenOn
import com.arflix.tv.ui.components.SeasonContextMenu
@@ -245,6 +249,12 @@ fun DetailsScreen(
var contextMenuEpisode by remember { mutableStateOf(null) }
var showSeasonContextMenu by remember { mutableStateOf(false) }
var contextMenuSeason by remember { mutableIntStateOf(1) }
+
+ // Download sheet state (mobile only)
+ var showDownloadSheet by remember { mutableStateOf(false) }
+ var downloadSheetSeason by remember { mutableStateOf(null) }
+ var downloadSheetEpisode by remember { mutableStateOf(null) }
+ var downloadSheetEpisodeTitle by remember { mutableStateOf(null) }
var seasonSelectDownAtMs by remember { mutableLongStateOf(0L) }
var ignoreFirstResumeRefresh by remember(mediaType, mediaId, initialSeason, initialEpisode) { mutableStateOf(true) }
@@ -272,6 +282,10 @@ fun DetailsScreen(
similarIndex = 0
isSidebarFocused = false
viewModel.loadDetails(mediaType, mediaId, initialSeason, initialEpisode)
+ if (isMobile) {
+ viewModel.startObservingDownloads(mediaId, mediaType)
+ viewModel.startObservingHomeServer()
+ }
}
LaunchedEffect(uiState.episodes.size, uiState.totalSeasons, uiState.cast.size, uiState.reviews.size, uiState.similar.size) {
@@ -875,9 +889,69 @@ fun DetailsScreen(
onSeasonClick = onSeasonClickRemembered,
onSeasonLongClick = onSeasonLongClickRemembered,
onEpisodeClick = onEpisodeClickRemembered,
+ onEpisodeLongClick = if (isMobile) { idx ->
+ val ep = uiState.episodes.getOrNull(idx)
+ if (ep != null) {
+ episodeIndex = idx
+ contextMenuEpisode = ep
+ showEpisodeContextMenu = true
+ }
+ } else null,
onCastClick = onCastClickRemembered,
onSimilarClick = onSimilarClickRemembered,
- onCollectionClick = onCollectionClickRemembered
+ onCollectionClick = onCollectionClickRemembered,
+ movieDownload = if (isMobile && uiState.hasHomeServer) uiState.movieDownload else null,
+ onMovieDownloadClick = if (isMobile && uiState.hasHomeServer && mediaType == MediaType.MOVIE) ({
+ val dl = uiState.movieDownload
+ when (dl?.status) {
+ com.arflix.tv.data.db.DownloadStatus.COMPLETED.name -> {
+ viewModel.deleteEpisodeDownload(dl.id)
+ }
+ com.arflix.tv.data.db.DownloadStatus.FAILED.name -> {
+ viewModel.retryEpisodeDownload(dl.id)
+ }
+ null -> {
+ viewModel.loadStreams(uiState.imdbId, null, null)
+ downloadSheetSeason = null
+ downloadSheetEpisode = null
+ downloadSheetEpisodeTitle = null
+ showDownloadSheet = true
+ }
+ else -> Unit
+ }
+ }) else null,
+ episodeDownload = if (isMobile && uiState.hasHomeServer && mediaType == MediaType.TV) {
+ val s = uiState.playSeason
+ val e = uiState.playEpisode
+ if (s != null && e != null) uiState.episodeDownloads["${s}_${e}"] else null
+ } else null,
+ tvDownloadLabel = if (mediaType == MediaType.TV && uiState.playSeason != null && uiState.playEpisode != null) {
+ "S${uiState.playSeason}E${uiState.playEpisode}"
+ } else null,
+ onEpisodeDownloadClick = if (isMobile && uiState.hasHomeServer && mediaType == MediaType.TV) ({
+ val s = uiState.playSeason
+ val e = uiState.playEpisode
+ val dl = if (s != null && e != null) uiState.episodeDownloads["${s}_${e}"] else null
+ when (dl?.status) {
+ com.arflix.tv.data.db.DownloadStatus.COMPLETED.name -> {
+ viewModel.deleteEpisodeDownload(dl.id)
+ }
+ com.arflix.tv.data.db.DownloadStatus.FAILED.name -> {
+ viewModel.retryEpisodeDownload(dl.id)
+ }
+ null -> {
+ val ep = uiState.episodes.firstOrNull {
+ it.seasonNumber == s && it.episodeNumber == e
+ }
+ viewModel.loadStreams(uiState.imdbId, s, e)
+ downloadSheetSeason = s
+ downloadSheetEpisode = e
+ downloadSheetEpisodeTitle = ep?.name
+ showDownloadSheet = true
+ }
+ else -> Unit
+ }
+ }) else null
)
}
}
@@ -940,6 +1014,33 @@ fun DetailsScreen(
}
}
+ // Download Sheet (mobile only)
+ if (isMobile) {
+ DownloadSheet(
+ isVisible = showDownloadSheet,
+ title = downloadSheetEpisodeTitle ?: uiState.item?.title ?: "",
+ streams = uiState.streams,
+ subtitles = (uiState.subtitles + uiState.openSubtitles)
+ .filter { it.url.isNotBlank() }
+ .distinctBy { it.url },
+ isLoadingStreams = uiState.isLoadingStreams,
+ isLoadingSubtitles = uiState.isLoadingSubtitles,
+ preferredSubtitleLang = uiState.preferredSubtitleLang,
+ secondarySubtitleLang = uiState.secondarySubtitleLang,
+ onConfirm = { stream, subtitle ->
+ showDownloadSheet = false
+ viewModel.enqueueDownload(
+ stream = stream,
+ season = downloadSheetSeason,
+ episode = downloadSheetEpisode,
+ episodeTitle = downloadSheetEpisodeTitle,
+ subtitle = subtitle
+ )
+ },
+ onDismiss = { showDownloadSheet = false }
+ )
+ }
+
// Stream Selector Modal
StreamSelector(
isVisible = showStreamSelector,
@@ -978,6 +1079,9 @@ fun DetailsScreen(
// Episode Context Menu
contextMenuEpisode?.let { episode ->
+ val epDownloadKey = "${episode.seasonNumber}_${episode.episodeNumber}"
+ val existingDownload = uiState.episodeDownloads[epDownloadKey]
+ val isEpDownloaded = existingDownload?.status == com.arflix.tv.data.db.DownloadStatus.COMPLETED.name
EpisodeContextMenu(
isVisible = showEpisodeContextMenu,
episodeName = episode.name,
@@ -1005,7 +1109,20 @@ fun DetailsScreen(
onDismiss = {
showEpisodeContextMenu = false
contextMenuEpisode = null
- }
+ },
+ isDownloaded = isEpDownloaded,
+ onDownload = if (isMobile && uiState.hasHomeServer && existingDownload == null) ({
+ showEpisodeContextMenu = false
+ downloadSheetSeason = episode.seasonNumber
+ downloadSheetEpisode = episode.episodeNumber
+ downloadSheetEpisodeTitle = episode.name
+ viewModel.loadStreams(uiState.imdbId, episode.seasonNumber, episode.episodeNumber)
+ showDownloadSheet = true
+ }) else null,
+ onRemoveDownload = if (isMobile && uiState.hasHomeServer && existingDownload != null) ({
+ showEpisodeContextMenu = false
+ viewModel.deleteEpisodeDownload(existingDownload.id)
+ }) else null
)
}
@@ -1189,14 +1306,81 @@ private fun DetailsContent(
onSeasonClick: (Int) -> Unit = {},
onSeasonLongClick: ((Int) -> Unit)? = null,
onEpisodeClick: (Int) -> Unit = {},
+ onEpisodeLongClick: ((Int) -> Unit)? = null,
onCastClick: (Int) -> Unit = {},
spoilerBlurEnabled: Boolean = false,
onSimilarClick: (Int) -> Unit = {},
- onCollectionClick: (Int) -> Unit = {}
+ onCollectionClick: (Int) -> Unit = {},
+ movieDownload: com.arflix.tv.data.db.DownloadEntity? = null,
+ onMovieDownloadClick: (() -> Unit)? = null,
+ episodeDownload: com.arflix.tv.data.db.DownloadEntity? = null,
+ tvDownloadLabel: String? = null,
+ onEpisodeDownloadClick: (() -> Unit)? = null
) {
val context = LocalContext.current
val metadataLogoImageLoader = context.imageLoader
val focusSectionForUi = if (contentHasFocus) focusedSection else null
+ var showDeleteConfirm by remember { mutableStateOf(false) }
+ var pendingDeleteAction by remember { mutableStateOf<(() -> Unit)?>(null) }
+
+ if (showDeleteConfirm) {
+ Dialog(onDismissRequest = { showDeleteConfirm = false; pendingDeleteAction = null }) {
+ Column(
+ modifier = Modifier
+ .clip(RoundedCornerShape(16.dp))
+ .background(Color(0xFF1A1A1A))
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ androidx.tv.material3.Text(
+ text = "Delete download?",
+ style = ArflixTypography.sectionTitle,
+ color = Color.White
+ )
+ androidx.tv.material3.Text(
+ text = "This will remove the downloaded file from your device.",
+ style = ArflixTypography.body,
+ color = com.arflix.tv.ui.theme.TextSecondary
+ )
+ Spacer(Modifier.height(4.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End)
+ ) {
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .clickable { showDeleteConfirm = false; pendingDeleteAction = null }
+ .padding(horizontal = 16.dp, vertical = 10.dp)
+ ) {
+ androidx.tv.material3.Text(
+ text = "Cancel",
+ style = ArflixTypography.body,
+ color = com.arflix.tv.ui.theme.TextSecondary
+ )
+ }
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .background(Color(0xFFFF5252).copy(alpha = 0.15f))
+ .clickable {
+ showDeleteConfirm = false
+ pendingDeleteAction?.invoke()
+ pendingDeleteAction = null
+ }
+ .padding(horizontal = 16.dp, vertical = 10.dp)
+ ) {
+ androidx.tv.material3.Text(
+ text = "Delete",
+ style = ArflixTypography.body,
+ color = Color(0xFFFF5252)
+ )
+ }
+ }
+ }
+ }
+ }
+
// === PREMIUM LAYERED TEXT SHADOWS ===
val textShadow = Shadow(
color = Color.Black.copy(alpha = 0.9f),
@@ -1420,17 +1604,59 @@ private fun DetailsContent(
) {
Spacer(modifier = Modifier.height(12.dp))
- // Primary mobile actions
+ // Play + Download in the same row
val playButtonLabel = if (!playLabel.isNullOrBlank()) playLabel else "Play"
+ val downloadHandler: (() -> Unit)? = when (item.mediaType) {
+ MediaType.MOVIE -> onMovieDownloadClick
+ MediaType.TV -> onEpisodeDownloadClick
+ else -> null
+ }
+ val activeDownload = when (item.mediaType) {
+ MediaType.MOVIE -> movieDownload
+ MediaType.TV -> episodeDownload
+ else -> null
+ }
+ val activeDlStatus = activeDownload?.status?.let {
+ runCatching { com.arflix.tv.data.db.DownloadStatus.valueOf(it) }.getOrNull()
+ }
+ val dlSuffix = tvDownloadLabel?.let { " $it" } ?: ""
+
MobileActionButton(
icon = Icons.Default.PlayArrow,
text = playButtonLabel,
isPrimary = true,
- modifier = Modifier
- .fillMaxWidth()
- .height(58.dp),
+ modifier = Modifier.fillMaxWidth().height(58.dp),
onClick = { onButtonClick(0) }
)
+ if (downloadHandler != null) {
+ Spacer(modifier = Modifier.height(8.dp))
+ MobileActionButton(
+ icon = when (activeDlStatus) {
+ com.arflix.tv.data.db.DownloadStatus.COMPLETED -> Icons.Default.CheckCircle
+ else -> Icons.Default.Download
+ },
+ text = when (activeDlStatus) {
+ com.arflix.tv.data.db.DownloadStatus.COMPLETED -> "Downloaded$dlSuffix"
+ com.arflix.tv.data.db.DownloadStatus.DOWNLOADING -> "Downloading ${activeDownload?.progress ?: 0}%"
+ com.arflix.tv.data.db.DownloadStatus.QUEUED -> "Queued"
+ com.arflix.tv.data.db.DownloadStatus.PAUSED -> "Paused"
+ com.arflix.tv.data.db.DownloadStatus.FAILED -> "Retry$dlSuffix"
+ null -> "Download$dlSuffix"
+ },
+ isPrimary = false,
+ isOutlined = true,
+ isActive = activeDlStatus == com.arflix.tv.data.db.DownloadStatus.COMPLETED,
+ modifier = Modifier.fillMaxWidth().height(58.dp),
+ onClick = {
+ if (activeDlStatus == com.arflix.tv.data.db.DownloadStatus.COMPLETED) {
+ pendingDeleteAction = downloadHandler
+ showDeleteConfirm = true
+ } else {
+ downloadHandler()
+ }
+ }
+ )
+ }
Spacer(modifier = Modifier.height(16.dp))
@@ -1555,7 +1781,8 @@ private fun DetailsContent(
episode = episode,
isFocused = false,
spoilerBlurEnabled = spoilerBlurEnabled,
- onClick = { onEpisodeClick(index) }
+ onClick = { onEpisodeClick(index) },
+ onLongClick = onEpisodeLongClick?.let { cb -> { cb(index) } }
)
}
}
@@ -3054,7 +3281,7 @@ private fun MobileActionButton(
val shape = RoundedCornerShape(percent = 50)
val bgColor = when {
isPrimary -> Color.White
- isOutlined -> Color.Transparent
+ isOutlined -> Color.White.copy(alpha = 0.06f)
isActive -> Color.White.copy(alpha = 0.15f)
else -> Color.White.copy(alpha = 0.08f)
}
@@ -3297,7 +3524,8 @@ private fun EpisodeCard(
cardWidth: androidx.compose.ui.unit.Dp = 300.dp,
isFocused: Boolean,
spoilerBlurEnabled: Boolean = false,
- onClick: () -> Unit = {}
+ onClick: () -> Unit = {},
+ onLongClick: (() -> Unit)? = null
) {
val aspectRatio = 16f / 9f
val context = LocalContext.current
@@ -3361,6 +3589,7 @@ private fun EpisodeCard(
enableSystemFocus = false,
isFocusedOverride = isFocused,
onClick = onClick,
+ onLongClick = onLongClick,
) { _ ->
Box(modifier = Modifier.fillMaxSize()) {
AsyncImage(
diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt
index 3cbe3cbb..2ca3c3f0 100644
--- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt
+++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt
@@ -21,6 +21,8 @@ import com.arflix.tv.data.repository.MediaRepository
import com.arflix.tv.data.repository.ProfileManager
import com.arflix.tv.data.repository.StreamRepository
import com.arflix.tv.data.repository.TraktRepository
+import com.arflix.tv.data.db.DownloadEntity
+import com.arflix.tv.data.repository.DownloadsRepository
import com.arflix.tv.data.repository.WatchHistoryRepository
import com.arflix.tv.data.repository.WatchlistRepository
import com.arflix.tv.util.Constants
@@ -61,9 +63,11 @@ data class DetailsUiState(
// Streams
val streams: List = emptyList(),
val subtitles: List = emptyList(),
+ val openSubtitles: List = emptyList(),
val isLoadingStreams: Boolean = false,
val completedAddons: Int = 0,
val totalAddons: Int = 0,
+ val isLoadingSubtitles: Boolean = false,
val hasStreamingAddons: Boolean = true,
val addonOrderedIds: List = emptyList(),
val isInWatchlist: Boolean = false,
@@ -91,11 +95,17 @@ data class DetailsUiState(
val playPositionMs: Long? = null,
val autoPlaySingleSource: Boolean = true,
val autoPlayMinQuality: String = "Any",
+ val preferredSubtitleLang: String = "",
+ val secondarySubtitleLang: String = "",
// TMDB collection (franchise) info — populated for movies that belong to a collection
val collectionId: Int? = null,
val collectionName: String? = null,
val collectionItems: List = emptyList(),
- val collectionPosterPath: String? = null
+ val collectionPosterPath: String? = null,
+ // Downloads (mobile only)
+ val episodeDownloads: Map = emptyMap(),
+ val movieDownload: DownloadEntity? = null,
+ val hasHomeServer: Boolean = false
)
data class StreamingServiceUi(
@@ -178,7 +188,9 @@ class DetailsViewModel @Inject constructor(
private val watchHistoryRepository: WatchHistoryRepository,
private val watchlistRepository: WatchlistRepository,
private val cloudSyncRepository: CloudSyncRepository,
- private val launcherContinueWatchingRepository: LauncherContinueWatchingRepository
+ private val launcherContinueWatchingRepository: LauncherContinueWatchingRepository,
+ private val downloadsRepository: DownloadsRepository,
+ private val homeServerRepository: HomeServerRepository
) : ViewModel() {
companion object {
@@ -197,6 +209,7 @@ class DetailsViewModel @Inject constructor(
private var vodAppendJob: kotlinx.coroutines.Job? = null
private var homeServerAppendJob: kotlinx.coroutines.Job? = null
private var loadStreamsJob: kotlinx.coroutines.Job? = null
+ private var subtitleFetchJob: kotlinx.coroutines.Job? = null
private var loadStreamsRequestId: Long = 0L
private var focusedStreamPrewarmJob: kotlinx.coroutines.Job? = null
private var streamListPrewarmJob: kotlinx.coroutines.Job? = null
@@ -206,6 +219,8 @@ class DetailsViewModel @Inject constructor(
private fun autoPlaySingleSourceKey() = profileManager.profileBooleanKey("auto_play_single_source")
private fun autoPlayMinQualityKey() = profileManager.profileStringKey("auto_play_min_quality")
private fun showBudgetKey() = profileManager.profileBooleanKey("show_budget_on_home")
+ private fun defaultSubtitleKey() = profileManager.profileStringKey("default_subtitle")
+ private fun secondarySubtitleKey() = profileManager.profileStringKey("secondary_subtitle")
private fun isBlankRating(value: String): Boolean {
return value.isBlank() || value == "0.0" || value == "0"
@@ -263,6 +278,8 @@ class DetailsViewModel @Inject constructor(
val autoPlaySingleSource = prefs[autoPlaySingleSourceKey()] ?: true
val autoPlayMinQuality = normalizeAutoPlayMinQuality(prefs[autoPlayMinQualityKey()])
val showBudget = prefs[showBudgetKey()] ?: true
+ val preferredSubtitleLang = prefs[defaultSubtitleKey()]?.trim().orEmpty()
+ val secondarySubtitleLang = prefs[secondarySubtitleKey()]?.trim().orEmpty()
val previousState = _uiState.value
val previousMatches = previousState.item?.id == mediaId &&
@@ -304,7 +321,9 @@ class DetailsViewModel @Inject constructor(
null
},
autoPlaySingleSource = autoPlaySingleSource,
- autoPlayMinQuality = autoPlayMinQuality
+ autoPlayMinQuality = autoPlayMinQuality,
+ preferredSubtitleLang = preferredSubtitleLang,
+ secondarySubtitleLang = secondarySubtitleLang
)
fun logDetailsLoadFailure(label: String, throwable: Throwable) {
@@ -1322,6 +1341,7 @@ class DetailsViewModel @Inject constructor(
fun loadStreams(imdbId: String?, season: Int? = null, episode: Int? = null) {
loadStreamsJob?.cancel()
+ subtitleFetchJob?.cancel()
focusedStreamPrewarmJob?.cancel()
streamListPrewarmJob?.cancel()
homeServerAppendJob?.cancel()
@@ -1377,9 +1397,29 @@ class DetailsViewModel @Inject constructor(
totalAddons = 0,
streams = emptyList(),
subtitles = emptyList(),
+ openSubtitles = emptyList(),
+ isLoadingSubtitles = !resolvedImdbId.isNullOrBlank(),
addonOrderedIds = orderedAddonIds
)
+ if (!resolvedImdbId.isNullOrBlank()) {
+ subtitleFetchJob = viewModelScope.launch {
+ val fetched = runCatching {
+ streamRepository.fetchSubtitlesForSelectedStream(
+ mediaType = requestMediaType,
+ imdbId = resolvedImdbId,
+ season = season,
+ episode = episode,
+ stream = null
+ )
+ }.getOrDefault(emptyList())
+ _uiState.value = _uiState.value.copy(
+ openSubtitles = fetched.filter { it.url.isNotBlank() },
+ isLoadingSubtitles = false
+ )
+ }
+ }
+
if (requestMediaType == MediaType.MOVIE) {
val title = _uiState.value.item?.title.orEmpty()
Log.d(
@@ -2495,6 +2535,77 @@ class DetailsViewModel @Inject constructor(
)
prewarmVisibleStreams(mergedStreams)
}
+
+ // ── Downloads ─────────────────────────────────────────────────────────────
+
+ private var downloadObserveJob: kotlinx.coroutines.Job? = null
+ private var homeServerObserveJob: kotlinx.coroutines.Job? = null
+
+ fun startObservingHomeServer() {
+ if (homeServerObserveJob?.isActive == true) return
+ homeServerObserveJob = viewModelScope.launch {
+ homeServerRepository.connections.collect { connections ->
+ _uiState.value = _uiState.value.copy(
+ hasHomeServer = connections.any { it.isUsable }
+ )
+ }
+ }
+ }
+
+ fun startObservingDownloads(tmdbId: Int, mediaType: MediaType) {
+ downloadObserveJob?.cancel()
+ downloadObserveJob = viewModelScope.launch {
+ downloadsRepository.observeDownloadsForMedia(tmdbId).collect { downloads ->
+ val episodeMap = downloads
+ .filter { it.season != null && it.episode != null }
+ .associateBy { "${it.season}_${it.episode}" }
+ val movieDl = downloads.firstOrNull { it.mediaType == "movie" }
+ _uiState.value = _uiState.value.copy(
+ episodeDownloads = episodeMap,
+ movieDownload = movieDl
+ )
+ }
+ }
+ }
+
+ fun enqueueDownload(
+ stream: com.arflix.tv.data.model.StreamSource,
+ season: Int?,
+ episode: Int?,
+ episodeTitle: String?,
+ subtitle: com.arflix.tv.data.model.Subtitle?
+ ) {
+ val item = _uiState.value.item ?: return
+ val mediaTypeStr = currentMediaType.name.lowercase()
+ viewModelScope.launch {
+ downloadsRepository.enqueueDownload(
+ tmdbId = currentMediaId,
+ mediaType = mediaTypeStr,
+ season = season,
+ episode = episode,
+ title = item.title,
+ episodeTitle = episodeTitle,
+ posterPath = item.image,
+ backdropPath = item.backdrop,
+ streamUrl = stream.url ?: return@launch,
+ addonId = stream.addonId,
+ addonName = stream.addonName,
+ quality = stream.quality,
+ subtitleUrl = subtitle?.url,
+ subtitleLang = subtitle?.lang,
+ headers = stream.behaviorHints?.proxyHeaders?.request
+ ?.filterKeys { it.isNotBlank() }
+ ?.takeIf { it.isNotEmpty() }
+ )
+ showToast("Download started", ToastType.SUCCESS)
+ }
+ }
+
+ fun deleteEpisodeDownload(id: Long) = viewModelScope.launch { downloadsRepository.deleteDownload(id) }
+ fun pauseEpisodeDownload(id: Long) = viewModelScope.launch { downloadsRepository.pauseDownload(id) }
+ fun resumeEpisodeDownload(id: Long) = viewModelScope.launch { downloadsRepository.resumeDownload(id) }
+ fun cancelEpisodeDownload(id: Long) = viewModelScope.launch { downloadsRepository.cancelDownload(id) }
+ fun retryEpisodeDownload(id: Long) = viewModelScope.launch { downloadsRepository.retryDownload(id) }
}
private object DetailsVMRegexes {
diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/downloads/DownloadedEpisodesScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/downloads/DownloadedEpisodesScreen.kt
new file mode 100644
index 00000000..78a1e7f9
--- /dev/null
+++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/downloads/DownloadedEpisodesScreen.kt
@@ -0,0 +1,305 @@
+package com.arflix.tv.ui.screens.downloads
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material.icons.filled.HourglassEmpty
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Text
+import coil.compose.AsyncImage
+import com.arflix.tv.data.db.DownloadEntity
+import com.arflix.tv.data.db.DownloadStatus
+import com.arflix.tv.ui.components.DownloadActionsSheet
+import com.arflix.tv.ui.theme.AccentGreen
+import com.arflix.tv.ui.theme.ArflixTypography
+import com.arflix.tv.ui.theme.TextSecondary
+import com.arflix.tv.ui.theme.appBackgroundDark
+import com.arflix.tv.util.formatBytes
+
+@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
+@Composable
+fun DownloadedEpisodesScreen(
+ tmdbId: Int,
+ title: String,
+ viewModel: DownloadsViewModel = hiltViewModel(),
+ onPlayEpisode: (DownloadEntity) -> Unit,
+ onBack: () -> Unit
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val episodes = uiState.seriesDownloads[tmdbId] ?: emptyList()
+ val completedCount = episodes.count { it.status == DownloadStatus.COMPLETED.name }
+ var actionSheetDownload by remember { mutableStateOf(null) }
+
+ val bySeason = episodes
+ .sortedWith(compareBy({ it.season ?: 0 }, { it.episode ?: 0 }))
+ .groupBy { it.season ?: 0 }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(appBackgroundDark())
+ ) {
+ // Header
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 8.dp)
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back", tint = Color.White)
+ }
+ Spacer(Modifier.width(8.dp))
+ Column {
+ Text(
+ text = title,
+ style = ArflixTypography.sectionTitle,
+ color = Color.White
+ )
+ Text(
+ text = "$completedCount of ${episodes.size} episodes downloaded",
+ style = ArflixTypography.caption,
+ color = TextSecondary
+ )
+ }
+ }
+
+ LazyColumn(
+ contentPadding = PaddingValues(bottom = 48.dp)
+ ) {
+ bySeason.forEach { (season, seasonEps) ->
+ stickyHeader(key = "season_$season") {
+ Text(
+ text = if (season == 0) "Specials" else "Season $season",
+ style = ArflixTypography.label,
+ color = TextSecondary,
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(appBackgroundDark())
+ .padding(horizontal = 16.dp, vertical = 10.dp)
+ )
+ }
+ seasonEps.forEach { ep ->
+ item(key = ep.id) {
+ EpisodeDownloadCard(
+ download = ep,
+ onClick = {
+ if (ep.status == DownloadStatus.COMPLETED.name) {
+ onPlayEpisode(ep)
+ }
+ },
+ onLongPress = { actionSheetDownload = ep }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ DownloadActionsSheet(
+ download = actionSheetDownload,
+ onDismiss = { actionSheetDownload = null },
+ onPause = { viewModel.pause(it) },
+ onResume = { viewModel.resume(it) },
+ onCancel = { viewModel.cancel(it) },
+ onDelete = { viewModel.delete(it) },
+ onRetry = { viewModel.retry(it) }
+ )
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class, ExperimentalTvMaterial3Api::class)
+@Composable
+private fun EpisodeDownloadCard(
+ download: DownloadEntity,
+ onClick: () -> Unit,
+ onLongPress: () -> Unit
+) {
+ val status = DownloadStatus.entries.find { it.name == download.status } ?: DownloadStatus.QUEUED
+ val epLabel = buildString {
+ download.season?.let { append("S${it.toString().padStart(2, '0')}") }
+ download.episode?.let { append("E${it.toString().padStart(2, '0')}") }
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(onClick = onClick, onLongClick = onLongPress)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ // Thumbnail
+ Box(
+ modifier = Modifier
+ .width(120.dp)
+ .height(68.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .background(Color(0xFF1A1A1A)),
+ contentAlignment = Alignment.Center
+ ) {
+ val imageUrl = download.backdropPath ?: download.posterPath
+ if (!imageUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = imageUrl,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize()
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.25f))
+ )
+ }
+ when (status) {
+ DownloadStatus.COMPLETED -> Icon(
+ Icons.Default.PlayArrow,
+ contentDescription = null,
+ tint = Color.White.copy(alpha = 0.9f),
+ modifier = Modifier.size(28.dp)
+ )
+ DownloadStatus.PAUSED -> Icon(
+ Icons.Default.Pause,
+ contentDescription = null,
+ tint = Color(0xFFFFCD3C),
+ modifier = Modifier.size(28.dp)
+ )
+ DownloadStatus.QUEUED -> Icon(
+ Icons.Default.HourglassEmpty,
+ contentDescription = null,
+ tint = Color.White.copy(alpha = 0.7f),
+ modifier = Modifier.size(22.dp)
+ )
+ DownloadStatus.FAILED -> Icon(
+ Icons.Default.Error,
+ contentDescription = null,
+ tint = Color.Red,
+ modifier = Modifier.size(24.dp)
+ )
+ DownloadStatus.DOWNLOADING -> Unit
+ }
+ }
+
+ // Info
+ Column(modifier = Modifier.weight(1f).padding(end = 4.dp)) {
+ if (epLabel.isNotEmpty()) {
+ Text(
+ text = epLabel,
+ style = ArflixTypography.caption,
+ color = TextSecondary,
+ fontSize = 11.sp
+ )
+ }
+ download.episodeTitle?.let {
+ Text(
+ text = it,
+ style = ArflixTypography.body,
+ color = Color.White,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ Spacer(Modifier.height(4.dp))
+ when (status) {
+ DownloadStatus.DOWNLOADING -> {
+ LinearProgressIndicator(
+ progress = { download.progress / 100f },
+ modifier = Modifier.fillMaxWidth().height(2.dp),
+ color = AccentGreen,
+ trackColor = Color.White.copy(alpha = 0.2f)
+ )
+ Spacer(Modifier.height(3.dp))
+ Text(
+ text = "${download.progress}% · ${formatBytes(download.downloadedBytes)}",
+ style = ArflixTypography.caption,
+ color = AccentGreen,
+ fontSize = 10.sp
+ )
+ }
+ DownloadStatus.COMPLETED -> Text(
+ text = formatBytes(download.fileSize),
+ style = ArflixTypography.caption,
+ color = TextSecondary,
+ fontSize = 10.sp
+ )
+ DownloadStatus.PAUSED -> {
+ LinearProgressIndicator(
+ progress = { download.progress / 100f },
+ modifier = Modifier.fillMaxWidth().height(2.dp),
+ color = Color(0xFFFFCD3C),
+ trackColor = Color.White.copy(alpha = 0.2f)
+ )
+ Spacer(Modifier.height(3.dp))
+ Text(
+ text = "Paused · ${download.progress}%",
+ style = ArflixTypography.caption,
+ color = Color(0xFFFFCD3C),
+ fontSize = 10.sp
+ )
+ }
+ DownloadStatus.QUEUED -> Text(
+ text = "Queued",
+ style = ArflixTypography.caption,
+ color = TextSecondary,
+ fontSize = 10.sp
+ )
+ DownloadStatus.FAILED -> Text(
+ text = "Failed — tap ⋮ to retry",
+ style = ArflixTypography.caption,
+ color = Color.Red,
+ fontSize = 10.sp
+ )
+ }
+ }
+
+ IconButton(onClick = onLongPress) {
+ Icon(
+ imageVector = Icons.Default.MoreVert,
+ contentDescription = "Options",
+ tint = TextSecondary,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+}
+
diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/downloads/DownloadsTab.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/downloads/DownloadsTab.kt
new file mode 100644
index 00000000..f0cbddc1
--- /dev/null
+++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/downloads/DownloadsTab.kt
@@ -0,0 +1,514 @@
+package com.arflix.tv.ui.screens.downloads
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.zIndex
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Text
+import coil.compose.AsyncImage
+import com.arflix.tv.data.db.DownloadEntity
+import com.arflix.tv.data.db.DownloadStatus
+import com.arflix.tv.ui.components.CardLayoutMode
+import com.arflix.tv.ui.components.DownloadActionsSheet
+import com.arflix.tv.ui.components.rememberCardLayoutMode
+import com.arflix.tv.ui.theme.ArflixTypography
+import com.arflix.tv.ui.theme.TextSecondary
+import com.arflix.tv.util.formatBytes
+
+@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
+@Composable
+fun DownloadsTab(
+ uiState: DownloadsViewModel.UiState,
+ onPlayMovie: (DownloadEntity) -> Unit,
+ onSeriesClick: (tmdbId: Int, title: String) -> Unit,
+ onPause: (Long) -> Unit,
+ onResume: (Long) -> Unit,
+ onCancel: (Long) -> Unit,
+ onDelete: (Long) -> Unit,
+ onRetry: (Long) -> Unit,
+ onDeleteAllSeries: (Int) -> Unit = {}
+) {
+ val isEmpty = uiState.movieDownloads.isEmpty() && uiState.seriesDownloads.isEmpty()
+ val layoutMode = rememberCardLayoutMode()
+ val usePoster = layoutMode == CardLayoutMode.POSTER
+ var actionSheetDownload by remember { mutableStateOf(null) }
+ var seriesActionSheet by remember { mutableStateOf?>(null) }
+
+ if (isEmpty) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Icon(
+ imageVector = Icons.Default.Download,
+ contentDescription = null,
+ tint = Color.White.copy(alpha = 0.2f),
+ modifier = Modifier.size(80.dp)
+ )
+ Spacer(Modifier.height(16.dp))
+ Text(
+ text = "No downloads yet",
+ style = ArflixTypography.body,
+ color = Color.White.copy(alpha = 0.5f)
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = "Download movies and episodes for offline watching",
+ style = ArflixTypography.caption,
+ color = Color.White.copy(alpha = 0.3f)
+ )
+ }
+ }
+ return
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ ) {
+ if (uiState.movieDownloads.isNotEmpty()) {
+ Text(
+ text = "Movies",
+ style = ArflixTypography.sectionTitle,
+ color = Color.White,
+ modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 12.dp)
+ )
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ contentPadding = PaddingValues(horizontal = 16.dp)
+ ) {
+ items(uiState.movieDownloads, key = { it.id }) { download ->
+ DownloadPosterCard(
+ download = download,
+ usePoster = usePoster,
+ onClick = {
+ if (download.status == DownloadStatus.COMPLETED.name) {
+ onPlayMovie(download)
+ }
+ },
+ onLongPress = { actionSheetDownload = download }
+ )
+ }
+ }
+ Spacer(Modifier.height(24.dp))
+ }
+
+ if (uiState.seriesDownloads.isNotEmpty()) {
+ Text(
+ text = "Series",
+ style = ArflixTypography.sectionTitle,
+ color = Color.White,
+ modifier = Modifier.padding(start = 16.dp, bottom = 12.dp)
+ )
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ contentPadding = PaddingValues(horizontal = 16.dp)
+ ) {
+ items(
+ uiState.seriesMetadata.entries.toList(),
+ key = { it.key }
+ ) { (tmdbId, representative) ->
+ val episodes = uiState.seriesDownloads[tmdbId] ?: emptyList()
+ val completedCount = episodes.count { it.status == DownloadStatus.COMPLETED.name }
+ SeriesPosterCard(
+ representative = representative,
+ episodeCount = completedCount,
+ totalCount = episodes.size,
+ usePoster = usePoster,
+ onClick = { onSeriesClick(tmdbId, representative.title) },
+ onLongPress = { seriesActionSheet = tmdbId to representative.title }
+ )
+ }
+ }
+ Spacer(Modifier.height(48.dp))
+ }
+ }
+
+ DownloadActionsSheet(
+ download = actionSheetDownload,
+ onDismiss = { actionSheetDownload = null },
+ onPause = onPause,
+ onResume = onResume,
+ onCancel = onCancel,
+ onDelete = onDelete,
+ onRetry = onRetry
+ )
+
+ SeriesActionsSheet(
+ series = seriesActionSheet,
+ onDismiss = { seriesActionSheet = null },
+ onDeleteAll = { tmdbId ->
+ seriesActionSheet = null
+ onDeleteAllSeries(tmdbId)
+ }
+ )
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class, ExperimentalTvMaterial3Api::class)
+@Composable
+private fun DownloadPosterCard(
+ download: DownloadEntity,
+ usePoster: Boolean,
+ onClick: () -> Unit,
+ onLongPress: () -> Unit
+) {
+ val status = DownloadStatus.entries.find { it.name == download.status } ?: DownloadStatus.QUEUED
+ val cardWidth = if (usePoster) 140.dp else 220.dp
+ val cardHeight = if (usePoster) 210.dp else 124.dp
+ val imageUrl = if (usePoster) download.posterPath
+ else download.backdropPath ?: download.posterPath
+
+ Column(modifier = Modifier.width(cardWidth)) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(cardHeight)
+ .clip(RoundedCornerShape(8.dp))
+ .background(Color(0xFF1A1A1A))
+ .combinedClickable(onClick = onClick, onLongClick = onLongPress)
+ ) {
+ if (!imageUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = imageUrl,
+ contentDescription = download.title,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+
+ when (status) {
+ DownloadStatus.DOWNLOADING -> {
+ LinearProgressIndicator(
+ progress = { download.progress / 100f },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(3.dp)
+ .align(Alignment.BottomCenter),
+ color = Color.White,
+ trackColor = Color.White.copy(alpha = 0.2f)
+ )
+ Text(
+ text = "${download.progress}%",
+ style = ArflixTypography.caption,
+ color = Color.White,
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(6.dp)
+ )
+ }
+ DownloadStatus.QUEUED -> {
+ CircularProgressIndicator(
+ modifier = Modifier.size(32.dp).align(Alignment.Center),
+ color = Color.White,
+ strokeWidth = 2.dp
+ )
+ }
+ DownloadStatus.PAUSED -> {
+ Icon(
+ Icons.Default.Pause,
+ contentDescription = "Paused",
+ tint = Color.White.copy(alpha = 0.9f),
+ modifier = Modifier.size(40.dp).align(Alignment.Center)
+ )
+ }
+ DownloadStatus.FAILED -> {
+ Icon(
+ Icons.Default.Error,
+ contentDescription = "Failed",
+ tint = Color.Red,
+ modifier = Modifier.size(40.dp).align(Alignment.Center)
+ )
+ }
+ DownloadStatus.COMPLETED -> {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .align(Alignment.Center)
+ .background(Color.Black.copy(alpha = 0.4f), RoundedCornerShape(20.dp)),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Default.PlayArrow,
+ contentDescription = "Play",
+ tint = Color.White,
+ modifier = Modifier.size(28.dp)
+ )
+ }
+ if (download.fileSize > 0L) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(6.dp)
+ .background(Color.Black.copy(alpha = 0.75f), RoundedCornerShape(4.dp))
+ .padding(horizontal = 6.dp, vertical = 2.dp)
+ ) {
+ Text(
+ text = formatBytes(download.fileSize),
+ style = ArflixTypography.caption,
+ color = Color.White,
+ fontSize = 10.sp
+ )
+ }
+ }
+ }
+ }
+
+ // MoreVert overlay
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(4.dp)
+ .background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(50))
+ .size(28.dp)
+ .clickable(onClick = onLongPress),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.MoreVert,
+ contentDescription = "Options",
+ tint = Color.White,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = download.title,
+ style = ArflixTypography.body,
+ color = Color.White,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
+@Composable
+private fun SeriesPosterCard(
+ representative: DownloadEntity,
+ episodeCount: Int,
+ totalCount: Int,
+ usePoster: Boolean,
+ onClick: () -> Unit,
+ onLongPress: () -> Unit
+) {
+ val cardWidth = if (usePoster) 140.dp else 220.dp
+ val cardHeight = if (usePoster) 210.dp else 124.dp
+ val imageUrl = if (usePoster) representative.posterPath
+ else representative.backdropPath ?: representative.posterPath
+
+ Column(modifier = Modifier.width(cardWidth)) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(cardHeight)
+ .clip(RoundedCornerShape(8.dp))
+ .background(Color(0xFF1A1A1A))
+ .combinedClickable(onClick = onClick, onLongClick = onLongPress)
+ ) {
+ if (!imageUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = imageUrl,
+ contentDescription = representative.title,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+
+ // Episode count badge
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(6.dp)
+ .background(Color.Black.copy(alpha = 0.75f), RoundedCornerShape(4.dp))
+ .padding(horizontal = 6.dp, vertical = 2.dp)
+ ) {
+ Text(
+ text = "$episodeCount/$totalCount",
+ style = ArflixTypography.caption,
+ color = Color.White,
+ fontSize = 10.sp
+ )
+ }
+
+ // MoreVert overlay
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(4.dp)
+ .background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(50))
+ .size(28.dp)
+ .clickable(onClick = onLongPress),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.MoreVert,
+ contentDescription = "Options",
+ tint = Color.White,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = representative.title,
+ style = ArflixTypography.body,
+ color = Color.White,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun SeriesActionsSheet(
+ series: Pair?,
+ onDismiss: () -> Unit,
+ onDeleteAll: (Int) -> Unit
+) {
+ val isVisible = series != null
+
+ AnimatedVisibility(
+ visible = isVisible,
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.6f))
+ .clickable(onClick = onDismiss)
+ .zIndex(20f)
+ )
+ }
+
+ AnimatedVisibility(
+ visible = isVisible,
+ enter = slideInVertically { it } + fadeIn(),
+ exit = slideOutVertically { it } + fadeOut()
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize().zIndex(21f),
+ contentAlignment = Alignment.BottomCenter
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
+ .background(Color(0xFF1A1A1A))
+ .navigationBarsPadding()
+ .padding(bottom = 8.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .padding(top = 12.dp, bottom = 8.dp)
+ .size(width = 40.dp, height = 4.dp)
+ .background(Color.White.copy(alpha = 0.3f), RoundedCornerShape(2.dp))
+ )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = series?.second.orEmpty(),
+ style = ArflixTypography.sectionTitle,
+ color = Color.White,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f)
+ )
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Close",
+ tint = Color.White,
+ modifier = Modifier
+ .size(24.dp)
+ .clickable(onClick = onDismiss)
+ )
+ }
+ HorizontalDivider(color = Color.White.copy(alpha = 0.1f))
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { series?.first?.let { onDeleteAll(it) } }
+ .padding(horizontal = 16.dp, vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = null,
+ tint = Color(0xFFFF5252),
+ modifier = Modifier.size(22.dp)
+ )
+ Text(
+ text = "Delete all downloads",
+ style = ArflixTypography.body,
+ color = Color(0xFFFF5252),
+ modifier = Modifier.padding(start = 16.dp)
+ )
+ }
+ HorizontalDivider(color = Color.White.copy(alpha = 0.07f))
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/downloads/DownloadsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/downloads/DownloadsViewModel.kt
new file mode 100644
index 00000000..2ecd4154
--- /dev/null
+++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/downloads/DownloadsViewModel.kt
@@ -0,0 +1,55 @@
+package com.arflix.tv.ui.screens.downloads
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.arflix.tv.data.db.DownloadEntity
+import com.arflix.tv.data.repository.DownloadsRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class DownloadsViewModel @Inject constructor(
+ private val repository: DownloadsRepository
+) : ViewModel() {
+
+ data class UiState(
+ val movieDownloads: List = emptyList(),
+ val seriesDownloads: Map> = emptyMap(),
+ val seriesMetadata: Map = emptyMap()
+ )
+
+ private val _uiState = MutableStateFlow(UiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ viewModelScope.launch {
+ repository.observeAllDownloads().collect { all ->
+ val movies = all.filter { it.mediaType == "movie" }
+ val tvAll = all.filter { it.mediaType == "tv" }
+ val seriesGroups = tvAll.groupBy { it.tmdbId }
+ val seriesMeta = seriesGroups.mapValues { (_, eps) ->
+ eps.maxByOrNull { it.createdAt } ?: eps.first()
+ }
+ _uiState.update {
+ it.copy(
+ movieDownloads = movies,
+ seriesDownloads = seriesGroups,
+ seriesMetadata = seriesMeta
+ )
+ }
+ }
+ }
+ }
+
+ fun pause(id: Long) = viewModelScope.launch { repository.pauseDownload(id) }
+ fun resume(id: Long) = viewModelScope.launch { repository.resumeDownload(id) }
+ fun cancel(id: Long) = viewModelScope.launch { repository.cancelDownload(id) }
+ fun delete(id: Long) = viewModelScope.launch { repository.deleteDownload(id) }
+ fun retry(id: Long) = viewModelScope.launch { repository.retryDownload(id) }
+ fun deleteAllForSeries(tmdbId: Int) = viewModelScope.launch { repository.deleteAllForSeries(tmdbId) }
+}
diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt
index e4747fe1..5362ce1c 100644
--- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt
+++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt
@@ -119,6 +119,7 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.database.StandaloneDatabaseProvider
+import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
@@ -248,8 +249,10 @@ fun PlayerScreen(
val castAvailable = castState !is CastManager.CastState.NotAvailable
// Hide cast button for streams that require custom request headers (Authorization, Referer, etc.)
// since the Chromecast default receiver fetches the URL directly without those headers.
+ // Also hide for offline (local file://) playback — Chromecast cannot reach device-local files.
val streamNeedsHeaders = uiState.selectedStream
?.behaviorHints?.proxyHeaders?.request?.isNotEmpty() == true
+ val castBlocked = streamNeedsHeaders || uiState.isOffline
val isConstrainedPlaybackDevice = remember(context, deviceType) {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
deviceType == com.arflix.tv.util.DeviceType.TV &&
@@ -717,6 +720,10 @@ fun PlayerScreen(
val directProgressiveFactory = remember(httpDataSourceFactory) {
ProgressiveMediaSource.Factory(httpDataSourceFactory)
}
+ // File-based factory for local downloads — uses FileDataSource, not OkHttp
+ val localFileFactory = remember(context) {
+ ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context))
+ }
// Protocol-specific media source factories for faster startup
val hlsFactory = remember(httpDataSourceFactory) {
@@ -1200,6 +1207,7 @@ fun PlayerScreen(
when (castState) {
is CastManager.CastState.Casting -> {
val url = uiState.selectedStreamUrl ?: return@LaunchedEffect
+ if (uiState.isOffline) return@LaunchedEffect // local file:// unreachable by Chromecast
val posMs = if (!playerReleased) exoPlayer.currentPosition else 0L
if (!playerReleased) exoPlayer.pause()
castManager.loadMedia(
@@ -1403,6 +1411,8 @@ fun PlayerScreen(
val isHeavy = isLikelyHeavyStream(latestUiState.selectedStream)
val isRemoteHttp = urlLower.startsWith("http://") || urlLower.startsWith("https://")
val mediaSource: MediaSource = when {
+ urlLower.startsWith("file://") ->
+ localFileFactory.createMediaSource(mediaItem)
urlLower.contains(".m3u8") || urlLower.contains("/hls") || urlLower.contains("format=hls") ->
hlsFactory.createMediaSource(mediaItem)
urlLower.contains(".mpd") || urlLower.contains("/dash") || urlLower.contains("format=dash") ->
@@ -1511,16 +1521,32 @@ fun PlayerScreen(
return@LaunchedEffect
}
- // External subtitle: rebuild MediaItem with just this one subtitle
+ // External subtitle: rebuild media source with just this one subtitle
if (subtitle.url.isNotBlank() && exoPlayer.playbackState != Player.STATE_IDLE) {
val currentPosition = exoPlayer.currentPosition
val wasPlaying = exoPlayer.isPlaying
val subtitleConfigs = buildExternalSubtitleConfigurations(listOf(subtitle))
- val mediaItem = MediaItem.Builder()
- .setUri(Uri.parse(url))
- .setSubtitleConfigurations(subtitleConfigs)
- .build()
- exoPlayer.setMediaItem(mediaItem, currentPosition)
+
+ if (url.lowercase().startsWith("file://")) {
+ // OkHttp cannot load file:// URIs. Use DefaultMediaSourceFactory backed by
+ // DefaultDataSource.Factory (supports file://) so the subtitle goes through
+ // the proper SRT→cues decoding pipeline instead of SingleSampleMediaSource
+ // which delivers raw bytes and crashes with legacy-decoding-disabled renderers.
+ val localMediaSourceFactory = DefaultMediaSourceFactory(
+ DefaultDataSource.Factory(context)
+ )
+ val mediaItem = MediaItem.Builder()
+ .setUri(Uri.parse(url))
+ .setSubtitleConfigurations(subtitleConfigs)
+ .build()
+ exoPlayer.setMediaSource(localMediaSourceFactory.createMediaSource(mediaItem), currentPosition)
+ } else {
+ val mediaItem = MediaItem.Builder()
+ .setUri(Uri.parse(url))
+ .setSubtitleConfigurations(subtitleConfigs)
+ .build()
+ exoPlayer.setMediaItem(mediaItem, currentPosition)
+ }
exoPlayer.prepare()
if (wasPlaying) exoPlayer.play()
@@ -2702,8 +2728,8 @@ fun PlayerScreen(
}
}
- // Cast button — mobile/tablet only; hidden when stream requires custom headers
- if (isTouchDevice && castAvailable && !streamNeedsHeaders) {
+ // Cast button — mobile/tablet only; hidden when stream requires custom headers or is offline
+ if (isTouchDevice && castAvailable && !castBlocked) {
val castDeviceName = (castState as? CastManager.CastState.Casting)?.deviceName
Row(
verticalAlignment = Alignment.CenterVertically,
diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt
index 599cf49b..2f431a74 100644
--- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt
+++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt
@@ -24,8 +24,12 @@ import com.arflix.tv.data.repository.LauncherContinueWatchingRepository
import com.arflix.tv.data.repository.TraktRepository
import com.arflix.tv.data.repository.WatchHistoryEntry
import com.arflix.tv.data.repository.WatchHistoryRepository
+import com.arflix.tv.data.db.DownloadStatus
+import com.arflix.tv.data.repository.DownloadsRepository
import com.arflix.tv.util.AppLogger
import com.arflix.tv.util.Constants
+import com.arflix.tv.util.isSubtitleLangDisabled
+import com.arflix.tv.util.normalizeSubtitleLang
import com.arflix.tv.util.settingsDataStore
import com.arflix.tv.util.weightedSubtitleScore
import androidx.datastore.preferences.core.booleanPreferencesKey
@@ -119,7 +123,9 @@ data class PlayerUiState(
// Plot synopsis from TMDB, used in the pause overlay metadata block
val overview: String? = null,
// Release year extracted from TMDB releaseDate/firstAirDate (e.g. "2023")
- val releaseYear: String? = null
+ val releaseYear: String? = null,
+ // True when playing a locally downloaded file (no network required)
+ val isOffline: Boolean = false
)
@@ -144,7 +150,8 @@ class PlayerViewModel @Inject constructor(
private val launcherContinueWatchingRepository: LauncherContinueWatchingRepository,
private val tmdbApi: TmdbApi,
private val skipIntroRepository: SkipIntroRepository,
- private val playbackTelemetryRepository: PlaybackTelemetryRepository
+ private val playbackTelemetryRepository: PlaybackTelemetryRepository,
+ private val downloadsRepository: DownloadsRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(PlayerUiState())
@@ -414,6 +421,44 @@ class PlayerViewModel @Inject constructor(
volumeBoostDb = volumeBoostDb
)
+ // Check for a completed local download before hitting the network.
+ val localDownload = downloadsRepository.getDownloadForEpisode(
+ tmdbId = mediaId,
+ mediaType = mediaType.name.lowercase(),
+ season = seasonNumber,
+ episode = episodeNumber
+ )
+ if (localDownload?.status == DownloadStatus.COMPLETED.name && localDownload.localUri != null) {
+ val localSubtitle = if (!localDownload.subtitleLocalUri.isNullOrBlank()) {
+ Subtitle(
+ id = "local_subtitle_${localDownload.id}",
+ url = "file://${localDownload.subtitleLocalUri}",
+ lang = localDownload.subtitleLang ?: "und",
+ label = localDownload.subtitleLang?.uppercase() ?: "Downloaded",
+ provider = "Downloaded"
+ )
+ } else null
+ val resumeData = resolveResumeData(
+ mediaType = mediaType,
+ mediaId = mediaId,
+ seasonNumber = seasonNumber,
+ episodeNumber = episodeNumber,
+ navigationStartPositionMs = startPositionMs
+ )
+ _uiState.value = _uiState.value.copy(
+ isLoading = false,
+ isLoadingStreams = false,
+ sourceSearchActive = false,
+ selectedStreamUrl = "file://${localDownload.localUri}",
+ isOffline = true,
+ subtitles = listOfNotNull(localSubtitle),
+ selectedSubtitle = localSubtitle,
+ savedPosition = resumeData.positionMs
+ )
+ launch { fetchMediaMetadata(mediaType, mediaId) }
+ return@launch
+ }
+
// If stream URL provided, use it directly (except magnet links, which require resolution).
if (providedStreamUrl != null) {
val resumeData = resolveResumeData(
@@ -467,13 +512,31 @@ class PlayerViewModel @Inject constructor(
return@launch
}
+ // For local file downloads, look up the subtitle from the DB by file path.
+ val localSubtitleForProvided = if (resolvedProvidedUrl?.startsWith("file://") == true) {
+ val localPath = resolvedProvidedUrl.removePrefix("file://")
+ val download = downloadsRepository.getDownloadByLocalUri(localPath)
+ if (download != null && !download.subtitleLocalUri.isNullOrBlank()) {
+ Subtitle(
+ id = "local_subtitle_${download.id}",
+ url = "file://${download.subtitleLocalUri}",
+ lang = download.subtitleLang ?: "und",
+ label = download.subtitleLang?.uppercase() ?: "Downloaded",
+ provider = "Downloaded"
+ )
+ } else null
+ } else null
+
_uiState.value = _uiState.value.copy(
isLoading = false,
isLoadingStreams = false,
sourceSearchActive = true,
selectedStream = resolvedProvidedStream,
selectedStreamUrl = resolvedProvidedUrl,
- savedPosition = resumeData.positionMs
+ savedPosition = resumeData.positionMs,
+ isOffline = resolvedProvidedUrl?.startsWith("file://") == true,
+ subtitles = listOfNotNull(localSubtitleForProvided),
+ selectedSubtitle = localSubtitleForProvided
)
launch {
kotlinx.coroutines.delay(1_500L)
@@ -1337,15 +1400,7 @@ class PlayerViewModel @Inject constructor(
}
}
- private fun isSubtitleDisabledPreference(value: String?): Boolean {
- val normalized = value?.trim()?.lowercase().orEmpty()
- return normalized.isBlank() ||
- normalized == "off" ||
- normalized == "none" ||
- normalized == "no subtitles" ||
- normalized == "disabled" ||
- normalized == "disable"
- }
+ private fun isSubtitleDisabledPreference(value: String?): Boolean = isSubtitleLangDisabled(value)
private fun qualityScore(quality: String): Int {
return when {
@@ -1781,102 +1836,7 @@ class PlayerViewModel @Inject constructor(
lower.contains("multi-audio")
}
- /**
- * Normalize language codes to a standard format for matching
- * Maps: "English" -> "en", "eng" -> "en", "Spanish" -> "es", etc.
- */
- private fun normalizeLanguage(lang: String): String {
- val lowerLang = lang.lowercase().trim()
- return when {
- // Full names
- lowerLang == "english" || lowerLang.startsWith("english") -> "en"
- lowerLang == "spanish" || lowerLang.startsWith("spanish") || lowerLang == "espanol" -> "es"
- lowerLang == "french" || lowerLang.startsWith("french") || lowerLang == "francais" -> "fr"
- lowerLang == "german" || lowerLang.startsWith("german") || lowerLang == "deutsch" -> "de"
- lowerLang == "italian" || lowerLang.startsWith("italian") -> "it"
- lowerLang == "portuguese" -> "pt"
- lowerLang == "portuguese (brazil)" ||
- lowerLang == "portuguese-brazil" ||
- lowerLang == "brazilian portuguese" ||
- lowerLang == "brazil portuguese" ||
- lowerLang == "pt-br" ||
- lowerLang == "ptbr" -> "pt-br"
- lowerLang.startsWith("portuguese") -> "pt"
- lowerLang == "dutch" || lowerLang.startsWith("dutch") -> "nl"
- lowerLang == "russian" || lowerLang.startsWith("russian") -> "ru"
- lowerLang == "chinese" || lowerLang.startsWith("chinese") -> "zh"
- lowerLang == "japanese" || lowerLang.startsWith("japanese") || lowerLang == "jp" || lowerLang == "jap" -> "ja"
- lowerLang == "korean" || lowerLang.startsWith("korean") -> "ko"
- lowerLang == "arabic" || lowerLang.startsWith("arabic") -> "ar"
- lowerLang == "hindi" || lowerLang.startsWith("hindi") -> "hi"
- lowerLang == "turkish" || lowerLang.startsWith("turkish") -> "tr"
- lowerLang == "polish" || lowerLang.startsWith("polish") -> "pl"
- lowerLang == "swedish" || lowerLang.startsWith("swedish") -> "sv"
- lowerLang == "norwegian" || lowerLang.startsWith("norwegian") -> "no"
- lowerLang == "danish" || lowerLang.startsWith("danish") -> "da"
- lowerLang == "finnish" || lowerLang.startsWith("finnish") -> "fi"
- lowerLang == "greek" || lowerLang.startsWith("greek") -> "el"
- lowerLang == "czech" || lowerLang.startsWith("czech") -> "cs"
- lowerLang == "hungarian" || lowerLang.startsWith("hungarian") -> "hu"
- lowerLang == "romanian" || lowerLang.startsWith("romanian") -> "ro"
- lowerLang == "thai" || lowerLang.startsWith("thai") -> "th"
- lowerLang == "vietnamese" || lowerLang.startsWith("vietnamese") -> "vi"
- lowerLang == "indonesian" || lowerLang.startsWith("indonesian") -> "id"
- lowerLang == "hebrew" || lowerLang.startsWith("hebrew") -> "he"
- lowerLang == "persian" || lowerLang.startsWith("persian") || lowerLang == "farsi" -> "fa"
- lowerLang == "ukrainian" || lowerLang.startsWith("ukrainian") -> "uk"
- lowerLang == "bengali" || lowerLang.startsWith("bengali") -> "bn"
- lowerLang == "bulgarian" || lowerLang.startsWith("bulgarian") -> "bg"
- lowerLang == "croatian" || lowerLang.startsWith("croatian") -> "hr"
- lowerLang == "serbian" || lowerLang.startsWith("serbian") -> "sr"
- lowerLang == "slovak" || lowerLang.startsWith("slovak") -> "sk"
- lowerLang == "slovenian" || lowerLang.startsWith("slovenian") -> "sl"
- lowerLang == "lithuanian" || lowerLang.startsWith("lithuanian") -> "lt"
- lowerLang == "estonian" || lowerLang.startsWith("estonian") -> "et"
- // ISO 639-1 codes (2 letter)
- lowerLang.length == 2 -> lowerLang
- // ISO 639-2 codes (3 letter)
- lowerLang == "eng" -> "en"
- lowerLang == "spa" -> "es"
- lowerLang == "fra" || lowerLang == "fre" -> "fr"
- lowerLang == "deu" || lowerLang == "ger" -> "de"
- lowerLang == "ita" -> "it"
- lowerLang == "por" -> "pt"
- lowerLang == "pob" || lowerLang == "pobr" -> "pt-br"
- lowerLang == "nld" || lowerLang == "dut" -> "nl"
- lowerLang == "rus" -> "ru"
- lowerLang == "zho" || lowerLang == "chi" -> "zh"
- lowerLang == "jpn" -> "ja"
- lowerLang == "kor" -> "ko"
- lowerLang == "ara" -> "ar"
- lowerLang == "hin" -> "hi"
- lowerLang == "tur" -> "tr"
- lowerLang == "pol" -> "pl"
- lowerLang == "swe" -> "sv"
- lowerLang == "nor" -> "no"
- lowerLang == "dan" -> "da"
- lowerLang == "fin" -> "fi"
- lowerLang == "ell" || lowerLang == "gre" -> "el"
- lowerLang == "ces" || lowerLang == "cze" -> "cs"
- lowerLang == "hun" -> "hu"
- lowerLang == "ron" || lowerLang == "rum" -> "ro"
- lowerLang == "tha" -> "th"
- lowerLang == "vie" -> "vi"
- lowerLang == "ind" -> "id"
- lowerLang == "heb" || lowerLang == "iw" -> "he"
- lowerLang == "fas" || lowerLang == "per" -> "fa"
- lowerLang == "ukr" -> "uk"
- lowerLang == "ben" -> "bn"
- lowerLang == "bul" -> "bg"
- lowerLang == "hrv" -> "hr"
- lowerLang == "srp" -> "sr"
- lowerLang == "slk" || lowerLang == "slo" -> "sk"
- lowerLang == "slv" -> "sl"
- lowerLang == "lit" -> "lt"
- lowerLang == "est" -> "et"
- else -> lowerLang
- }
- }
+ private fun normalizeLanguage(lang: String): String = normalizeSubtitleLang(lang)
// Track current stream index for auto-retry
private var currentStreamIndex = 0
diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt
index 9ad49fc2..859aa366 100644
--- a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt
+++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt
@@ -1,12 +1,16 @@
package com.arflix.tv.ui.screens.watchlist
import android.os.SystemClock
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -17,9 +21,12 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bookmark
import androidx.compose.material3.Icon
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -28,6 +35,7 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -48,6 +56,7 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text
+import com.arflix.tv.data.db.DownloadEntity
import com.arflix.tv.data.model.MediaItem
import com.arflix.tv.data.model.MediaType
import com.arflix.tv.ui.components.AppTopBar
@@ -61,6 +70,8 @@ import com.arflix.tv.ui.components.ToastType as ComponentToastType
import com.arflix.tv.ui.components.rememberCardLayoutMode
import com.arflix.tv.ui.components.topBarFocusedItem
import com.arflix.tv.ui.components.topBarMaxIndex
+import com.arflix.tv.ui.screens.downloads.DownloadsTab
+import com.arflix.tv.ui.screens.downloads.DownloadsViewModel
import com.arflix.tv.ui.theme.ArflixTypography
import com.arflix.tv.ui.theme.Pink
import com.arflix.tv.ui.theme.TextPrimary
@@ -73,17 +84,23 @@ import kotlinx.coroutines.delay
@Composable
fun WatchlistScreen(
viewModel: WatchlistViewModel = hiltViewModel(),
+ downloadsViewModel: DownloadsViewModel = hiltViewModel(),
+ initialTab: Int = 0,
currentProfile: com.arflix.tv.data.model.Profile? = null,
onNavigateToDetails: (MediaType, Int) -> Unit = { _, _ -> },
onNavigateToHome: () -> Unit = {},
onNavigateToSearch: () -> Unit = {},
onNavigateToTv: () -> Unit = {},
onNavigateToSettings: () -> Unit = {},
+ onNavigateToDownloadedEpisodes: (tmdbId: Int, title: String) -> Unit = { _, _ -> },
+ onNavigateToPlayer: (DownloadEntity) -> Unit = {},
onSwitchProfile: () -> Unit = {},
onBack: () -> Unit = {}
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val logoUrls by viewModel.logoUrls.collectAsStateWithLifecycle()
+ val downloadsUiState by downloadsViewModel.uiState.collectAsStateWithLifecycle()
+ var selectedTab by rememberSaveable { mutableIntStateOf(initialTab) }
val isMobile = LocalDeviceType.current.isTouchDevice()
val usePosterCards = rememberCardLayoutMode() == CardLayoutMode.POSTER
val cardWidth: Dp = if (usePosterCards) {
@@ -270,78 +287,144 @@ fun WatchlistScreen(
modifier = Modifier
.fillMaxSize()
.padding(top = if (isMobile) 0.dp else AppTopBarContentTopInset)
- .padding(start = 24.dp, top = 4.dp, end = 48.dp)
) {
- when {
- uiState.isLoading -> {
- Box(
- modifier = Modifier.fillMaxWidth().weight(1f),
- contentAlignment = Alignment.Center
- ) {
- LoadingIndicator(color = Pink, size = 64.dp)
- }
- }
- totalItems == 0 -> {
- Box(
- modifier = Modifier.fillMaxSize(),
- contentAlignment = Alignment.Center
- ) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Icon(
- imageVector = Icons.Outlined.Bookmark,
- contentDescription = null,
- tint = Color.White.copy(alpha = 0.2f),
- modifier = Modifier.size(80.dp)
- )
- Spacer(modifier = Modifier.height(16.dp))
- Text(
- text = tr("Your watchlist is empty"),
- style = ArflixTypography.body,
- color = Color.White.copy(alpha = 0.5f)
- )
- Spacer(modifier = Modifier.height(8.dp))
+ // Mobile-only animated pill segmented control
+ if (isMobile) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 14.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(Color.White.copy(alpha = 0.1f))
+ .padding(4.dp)
+ ) {
+ listOf(tr("Watchlist"), tr("Downloads")).forEachIndexed { index, label ->
+ val isSelected = selectedTab == index
+ val bgColor by animateColorAsState(
+ targetValue = if (isSelected) Color.White else Color.Transparent,
+ animationSpec = tween(durationMillis = 180),
+ label = "tabBg$index"
+ )
+ val textColor by animateColorAsState(
+ targetValue = if (isSelected) Color(0xFF111111) else Color.White.copy(alpha = 0.45f),
+ animationSpec = tween(durationMillis = 180),
+ label = "tabText$index"
+ )
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .clip(RoundedCornerShape(9.dp))
+ .background(bgColor)
+ .clickable { selectedTab = index }
+ .padding(vertical = 9.dp),
+ contentAlignment = Alignment.Center
+ ) {
Text(
- text = tr("Add movies and shows for later"),
- style = ArflixTypography.caption,
- color = Color.White.copy(alpha = 0.3f)
+ text = label,
+ style = ArflixTypography.label.copy(
+ fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium
+ ),
+ color = textColor
)
}
}
}
- else -> {
- LazyColumn(
- state = lazyColumnState,
- modifier = Modifier.weight(1f).fillMaxWidth().focusable(false),
- contentPadding = PaddingValues(top = if (isMobile) 48.dp else 0.dp, bottom = 16.dp),
- verticalArrangement = Arrangement.spacedBy(if (isMobile) 24.dp else 16.dp),
- userScrollEnabled = isMobile
- ) {
- itemsIndexed(
- items = sections,
- key = { _, (type, _) -> type },
- contentType = { _, _ -> "watchlist_section" }
- ) { sectionIdx, (sectionType, items) ->
- val title = when (sectionType) {
- "movies" -> tr("Movies")
- "series" -> tr("Series")
- else -> sectionType.replaceFirstChar { it.uppercase() }
+ }
+
+ if (isMobile && selectedTab == 1) {
+ DownloadsTab(
+ uiState = downloadsUiState,
+ onPlayMovie = { onNavigateToPlayer(it) },
+ onSeriesClick = { tmdbId, title -> onNavigateToDownloadedEpisodes(tmdbId, title) },
+ onPause = { downloadsViewModel.pause(it) },
+ onResume = { downloadsViewModel.resume(it) },
+ onCancel = { downloadsViewModel.cancel(it) },
+ onDelete = { downloadsViewModel.delete(it) },
+ onRetry = { downloadsViewModel.retry(it) },
+ onDeleteAllSeries = { downloadsViewModel.deleteAllForSeries(it) }
+ )
+ } else {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(
+ start = 24.dp,
+ top = if (!isMobile) 4.dp else 0.dp,
+ end = 48.dp
+ )
+ ) {
+ when {
+ uiState.isLoading -> {
+ Box(
+ modifier = Modifier.fillMaxWidth().weight(1f),
+ contentAlignment = Alignment.Center
+ ) {
+ LoadingIndicator(color = Pink, size = 64.dp)
}
- WatchlistItemsSection(
- title = title,
- items = items,
- logoUrls = logoUrls,
- cardWidth = cardWidth,
- isLandscape = !usePosterCards,
- isMobile = isMobile,
- focusedItemIndex = if (!isMobile && focusedSectionIndex == sectionIdx && !isSidebarFocused) focusedItemIndex else -1,
- onItemFocused = { index ->
- if (!isSidebarFocused && focusedSectionIndex == sectionIdx) {
- focusedItemIndex = index
+ }
+ totalItems == 0 -> {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Icon(
+ imageVector = Icons.Outlined.Bookmark,
+ contentDescription = null,
+ tint = Color.White.copy(alpha = 0.2f),
+ modifier = Modifier.size(80.dp)
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = tr("Your watchlist is empty"),
+ style = ArflixTypography.body,
+ color = Color.White.copy(alpha = 0.5f)
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = tr("Add movies and shows for later"),
+ style = ArflixTypography.caption,
+ color = Color.White.copy(alpha = 0.3f)
+ )
+ }
+ }
+ }
+ else -> {
+ LazyColumn(
+ state = lazyColumnState,
+ modifier = Modifier.weight(1f).fillMaxWidth().focusable(false),
+ contentPadding = PaddingValues(top = if (isMobile) 48.dp else 0.dp, bottom = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(if (isMobile) 24.dp else 16.dp),
+ userScrollEnabled = isMobile
+ ) {
+ itemsIndexed(
+ items = sections,
+ key = { _, (type, _) -> type },
+ contentType = { _, _ -> "watchlist_section" }
+ ) { sectionIdx, (sectionType, items) ->
+ val title = when (sectionType) {
+ "movies" -> tr("Movies")
+ "series" -> tr("Series")
+ else -> sectionType.replaceFirstChar { it.uppercase() }
}
- },
- onItemClick = { item -> onNavigateToDetails(item.mediaType, item.id) },
- onItemLongPress = { item -> viewModel.removeFromWatchlist(item) }
- )
+ WatchlistItemsSection(
+ title = title,
+ items = items,
+ logoUrls = logoUrls,
+ cardWidth = cardWidth,
+ isLandscape = !usePosterCards,
+ isMobile = isMobile,
+ focusedItemIndex = if (!isMobile && focusedSectionIndex == sectionIdx && !isSidebarFocused) focusedItemIndex else -1,
+ onItemFocused = { index ->
+ if (!isSidebarFocused && focusedSectionIndex == sectionIdx) {
+ focusedItemIndex = index
+ }
+ },
+ onItemClick = { item -> onNavigateToDetails(item.mediaType, item.id) },
+ onItemLongPress = { item -> viewModel.removeFromWatchlist(item) }
+ )
+ }
+ }
}
}
}
diff --git a/app/src/main/kotlin/com/arflix/tv/util/DownloadUtils.kt b/app/src/main/kotlin/com/arflix/tv/util/DownloadUtils.kt
new file mode 100644
index 00000000..8e1a50c5
--- /dev/null
+++ b/app/src/main/kotlin/com/arflix/tv/util/DownloadUtils.kt
@@ -0,0 +1,7 @@
+package com.arflix.tv.util
+
+fun formatBytes(bytes: Long): String = when {
+ bytes >= 1_000_000_000 -> "%.1f GB".format(bytes / 1_000_000_000.0)
+ bytes >= 1_000_000 -> "%.0f MB".format(bytes / 1_000_000.0)
+ else -> "${bytes / 1_000} KB"
+}
diff --git a/app/src/main/kotlin/com/arflix/tv/util/SubtitleLanguageMatcher.kt b/app/src/main/kotlin/com/arflix/tv/util/SubtitleLanguageMatcher.kt
new file mode 100644
index 00000000..c4236354
--- /dev/null
+++ b/app/src/main/kotlin/com/arflix/tv/util/SubtitleLanguageMatcher.kt
@@ -0,0 +1,160 @@
+package com.arflix.tv.util
+
+import com.arflix.tv.data.model.Subtitle
+
+/**
+ * Shared subtitle-language matching helpers.
+ *
+ * Consolidates logic previously duplicated between PlayerViewModel (preferred-language
+ * filtering during playback) and DownloadSheet (preferred-language filtering / auto-select
+ * for offline downloads). Keep this pure (no Android, no DataStore) so it stays testable
+ * and reusable from both UI and worker code.
+ */
+
+/** True when a saved language preference means "no subtitles". */
+fun isSubtitleLangDisabled(value: String?): Boolean {
+ val normalized = value?.trim()?.lowercase().orEmpty()
+ return normalized.isBlank() ||
+ normalized == "off" ||
+ normalized == "none" ||
+ normalized == "no subtitles" ||
+ normalized == "disabled" ||
+ normalized == "disable"
+}
+
+/**
+ * Normalize a language name / ISO 639-1 / ISO 639-2 code to a canonical short tag.
+ * Examples: "English" -> "en", "eng" -> "en", "pt-br" -> "pt-br".
+ * Unknown values are returned lowercased and trimmed.
+ */
+fun normalizeSubtitleLang(lang: String): String {
+ val lower = lang.lowercase().trim()
+ return when {
+ // Full names
+ lower == "english" || lower.startsWith("english") -> "en"
+ lower == "spanish" || lower.startsWith("spanish") || lower == "espanol" -> "es"
+ lower == "french" || lower.startsWith("french") || lower == "francais" -> "fr"
+ lower == "german" || lower.startsWith("german") || lower == "deutsch" -> "de"
+ lower == "italian" || lower.startsWith("italian") -> "it"
+ lower == "portuguese" -> "pt"
+ lower == "portuguese (brazil)" ||
+ lower == "portuguese-brazil" ||
+ lower == "brazilian portuguese" ||
+ lower == "brazil portuguese" ||
+ lower == "pt-br" ||
+ lower == "ptbr" -> "pt-br"
+ lower.startsWith("portuguese") -> "pt"
+ lower == "dutch" || lower.startsWith("dutch") -> "nl"
+ lower == "russian" || lower.startsWith("russian") -> "ru"
+ lower == "chinese" || lower.startsWith("chinese") -> "zh"
+ lower == "japanese" || lower.startsWith("japanese") || lower == "jp" || lower == "jap" -> "ja"
+ lower == "korean" || lower.startsWith("korean") -> "ko"
+ lower == "arabic" || lower.startsWith("arabic") -> "ar"
+ lower == "hindi" || lower.startsWith("hindi") -> "hi"
+ lower == "turkish" || lower.startsWith("turkish") -> "tr"
+ lower == "polish" || lower.startsWith("polish") -> "pl"
+ lower == "swedish" || lower.startsWith("swedish") -> "sv"
+ lower == "norwegian" || lower.startsWith("norwegian") -> "no"
+ lower == "danish" || lower.startsWith("danish") -> "da"
+ lower == "finnish" || lower.startsWith("finnish") -> "fi"
+ lower == "greek" || lower.startsWith("greek") -> "el"
+ lower == "czech" || lower.startsWith("czech") -> "cs"
+ lower == "hungarian" || lower.startsWith("hungarian") -> "hu"
+ lower == "romanian" || lower.startsWith("romanian") -> "ro"
+ lower == "thai" || lower.startsWith("thai") -> "th"
+ lower == "vietnamese" || lower.startsWith("vietnamese") -> "vi"
+ lower == "indonesian" || lower.startsWith("indonesian") -> "id"
+ lower == "hebrew" || lower.startsWith("hebrew") -> "he"
+ lower == "persian" || lower.startsWith("persian") || lower == "farsi" -> "fa"
+ lower == "ukrainian" || lower.startsWith("ukrainian") -> "uk"
+ lower == "bengali" || lower.startsWith("bengali") -> "bn"
+ lower == "bulgarian" || lower.startsWith("bulgarian") -> "bg"
+ lower == "croatian" || lower.startsWith("croatian") -> "hr"
+ lower == "serbian" || lower.startsWith("serbian") -> "sr"
+ lower == "slovak" || lower.startsWith("slovak") -> "sk"
+ lower == "slovenian" || lower.startsWith("slovenian") -> "sl"
+ lower == "lithuanian" || lower.startsWith("lithuanian") -> "lt"
+ lower == "estonian" || lower.startsWith("estonian") -> "et"
+ // ISO 639-1 codes (2 letter)
+ lower.length == 2 -> lower
+ // ISO 639-2 codes (3 letter)
+ lower == "eng" -> "en"
+ lower == "spa" -> "es"
+ lower == "fra" || lower == "fre" -> "fr"
+ lower == "deu" || lower == "ger" -> "de"
+ lower == "ita" -> "it"
+ lower == "por" -> "pt"
+ lower == "pob" || lower == "pobr" -> "pt-br"
+ lower == "nld" || lower == "dut" -> "nl"
+ lower == "rus" -> "ru"
+ lower == "zho" || lower == "chi" -> "zh"
+ lower == "jpn" -> "ja"
+ lower == "kor" -> "ko"
+ lower == "ara" -> "ar"
+ lower == "hin" -> "hi"
+ lower == "tur" -> "tr"
+ lower == "pol" -> "pl"
+ lower == "swe" -> "sv"
+ lower == "nor" -> "no"
+ lower == "dan" -> "da"
+ lower == "fin" -> "fi"
+ lower == "ell" || lower == "gre" -> "el"
+ lower == "ces" || lower == "cze" -> "cs"
+ lower == "hun" -> "hu"
+ lower == "ron" || lower == "rum" -> "ro"
+ lower == "tha" -> "th"
+ lower == "vie" -> "vi"
+ lower == "ind" -> "id"
+ lower == "heb" || lower == "iw" -> "he"
+ lower == "fas" || lower == "per" -> "fa"
+ lower == "ukr" -> "uk"
+ lower == "ben" -> "bn"
+ lower == "bul" -> "bg"
+ lower == "hrv" -> "hr"
+ lower == "srp" -> "sr"
+ lower == "slk" || lower == "slo" -> "sk"
+ lower == "slv" -> "sl"
+ lower == "lit" -> "lt"
+ lower == "est" -> "et"
+ else -> lower
+ }
+}
+
+/**
+ * True if [sub]'s lang or label normalizes to [normalizedLang]. For longer language tags
+ * (>2 chars) falls back to substring containment — avoids the classic "en" matches
+ * "Indonesian" false positive that simple `.contains()` would produce.
+ */
+fun subtitleMatchesLanguage(sub: Subtitle, normalizedLang: String): Boolean {
+ val tokens = setOf(
+ normalizeSubtitleLang(sub.lang),
+ normalizeSubtitleLang(sub.label)
+ )
+ if (normalizedLang in tokens) return true
+ if (normalizedLang.length > 2) {
+ return sub.lang.lowercase().contains(normalizedLang) ||
+ sub.label.lowercase().contains(normalizedLang)
+ }
+ return false
+}
+
+/**
+ * Filter [subs] to those matching [preferred] or [secondary] language preferences. Either
+ * preference may be a saved disabled-marker ("Off", blank, etc.) — those are ignored.
+ * Falls back to the unfiltered list when nothing matches so the user is never left with
+ * an empty subtitle picker.
+ */
+fun filterSubtitlesByLanguage(
+ subs: List,
+ preferred: String,
+ secondary: String
+): List {
+ val targets = listOf(preferred, secondary)
+ .filterNot { isSubtitleLangDisabled(it) }
+ .map { normalizeSubtitleLang(it) }
+ .filter { it.isNotBlank() }
+ .distinct()
+ if (targets.isEmpty()) return subs
+ val filtered = subs.filter { sub -> targets.any { subtitleMatchesLanguage(sub, it) } }
+ return filtered.ifEmpty { subs }
+}
diff --git a/app/src/main/kotlin/com/arflix/tv/worker/DownloadWorker.kt b/app/src/main/kotlin/com/arflix/tv/worker/DownloadWorker.kt
new file mode 100644
index 00000000..461e0fd8
--- /dev/null
+++ b/app/src/main/kotlin/com/arflix/tv/worker/DownloadWorker.kt
@@ -0,0 +1,245 @@
+package com.arflix.tv.worker
+
+import android.app.NotificationManager
+import android.content.Context
+import android.content.pm.ServiceInfo
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.work.CoroutineWorker
+import androidx.work.ForegroundInfo
+import androidx.work.WorkerParameters
+import com.arflix.tv.data.db.DownloadStatus
+import com.arflix.tv.data.repository.DownloadsRepository
+import com.arflix.tv.di.RepositoryAccessEntryPoint
+import com.arflix.tv.util.formatBytes
+import dagger.hilt.android.EntryPointAccessors
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.File
+import java.io.FileOutputStream
+import java.util.concurrent.TimeUnit
+
+class DownloadWorker(
+ appContext: Context,
+ params: WorkerParameters
+) : CoroutineWorker(appContext, params) {
+
+ companion object {
+ const val KEY_DOWNLOAD_ID = "download_id"
+ const val KEY_STREAM_URL = "stream_url"
+ const val KEY_SUBTITLE_URL = "subtitle_url"
+ const val KEY_USER_AGENT = "user_agent"
+ const val KEY_HEADERS = "headers"
+ const val KEY_TITLE = "title"
+ const val KEY_SEASON = "season"
+ const val KEY_EPISODE = "episode"
+ const val NOTIFICATION_CHANNEL_ID = "downloads"
+ private const val PROGRESS_UPDATE_INTERVAL_MS = 500L
+ private const val BUFFER_SIZE = 8 * 1024
+ }
+
+ private val repository: DownloadsRepository by lazy {
+ EntryPointAccessors.fromApplication(
+ applicationContext,
+ RepositoryAccessEntryPoint::class.java
+ ).downloadsRepository()
+ }
+
+ private val okHttpClient: OkHttpClient by lazy {
+ OkHttpClient.Builder()
+ .connectTimeout(60, TimeUnit.SECONDS)
+ .readTimeout(300, TimeUnit.SECONDS)
+ .build()
+ }
+
+ override suspend fun doWork(): Result {
+ val downloadId = inputData.getLong(KEY_DOWNLOAD_ID, -1L)
+ val streamUrl = inputData.getString(KEY_STREAM_URL) ?: return Result.failure()
+ val subtitleUrl = inputData.getString(KEY_SUBTITLE_URL)
+ val userAgent = inputData.getString(KEY_USER_AGENT).orEmpty()
+ val headersJson = inputData.getString(KEY_HEADERS).orEmpty()
+ val streamHeaders: Map = if (headersJson.isNotBlank()) {
+ runCatching {
+ val type = object : TypeToken