From db8ddb1022f39f489a217021725674145dabbc58 Mon Sep 17 00:00:00 2001 From: chill pill 244 Date: Mon, 1 Jun 2026 18:58:22 -0700 Subject: [PATCH 1/2] feat(downloads): offline download feature for movies and episodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds end-to-end offline download support for mobile users. Downloaded content is persisted in a local Room database and playable without an internet connection. Tested with IPTV and Jellyfin sources. **Core infrastructure** - Room database (`DownloadEntity`, `DownloadDao`, `ArflixDatabase`) tracking download state, progress, file path, and metadata - `DownloadsRepository` — insert, query, pause/resume/cancel/delete/retry helpers - `DownloadWorker` — streams via OkHttp with `Range` header support for pause/resume, foreground notifications with live progress **Downloads UI** - `DownloadsTab` — horizontal card rows for movies and series with poster/backdrop layout; long-press or MoreVert opens an actions sheet - `DownloadActionsSheet` — bottom sheet with pause, resume, cancel, delete, and retry actions - `DownloadedEpisodesScreen` — per-series episode list reachable from the Downloads tab - Series long-press shows a "Delete all downloads" sheet **Entry points** - Bottom bar shows a Downloads tab on mobile when any download exists - Details screen shows a Download / Downloading / Downloaded / Retry button below Play - Context menu on watchlist/catalog cards gains a Download option **Player** - Chromecast disabled for offline (`file://`) streams **Watchlist tab** - Apple TV-style animated pill segmented control (mobile-only) - Tab selection preserved across navigation with `rememberSaveable` **Stream headers & subtitles** - Stream header forwarding for authenticated sources - Subtitle track preferences persisted per profile Co-Authored-By: Claude Sonnet 4.6 --- app/build.gradle.kts | 5 + app/src/main/AndroidManifest.xml | 8 + .../kotlin/com/arflix/tv/ArflixApplication.kt | 10 + .../main/kotlin/com/arflix/tv/MainActivity.kt | 27 +- .../com/arflix/tv/data/db/ArflixDatabase.kt | 17 + .../com/arflix/tv/data/db/DownloadDao.kt | 64 +++ .../com/arflix/tv/data/db/DownloadEntity.kt | 40 ++ .../tv/data/repository/DownloadsRepository.kt | 201 +++++++ .../kotlin/com/arflix/tv/di/DatabaseModule.kt | 33 ++ .../tv/di/RepositoryAccessEntryPoint.kt | 2 + .../com/arflix/tv/navigation/AppNavigation.kt | 72 ++- .../arflix/tv/ui/components/AppBottomBar.kt | 72 ++- .../arflix/tv/ui/components/ContextMenu.kt | 24 +- .../tv/ui/components/DownloadActionsSheet.kt | 221 ++++++++ .../arflix/tv/ui/components/DownloadSheet.kt | 499 +++++++++++++++++ .../tv/ui/screens/details/DetailsScreen.kt | 246 ++++++++- .../tv/ui/screens/details/DetailsViewModel.kt | 103 +++- .../downloads/DownloadedEpisodesScreen.kt | 305 +++++++++++ .../tv/ui/screens/downloads/DownloadsTab.kt | 514 ++++++++++++++++++ .../screens/downloads/DownloadsViewModel.kt | 55 ++ .../tv/ui/screens/player/PlayerScreen.kt | 42 +- .../tv/ui/screens/player/PlayerViewModel.kt | 176 +++--- .../ui/screens/watchlist/WatchlistScreen.kt | 211 ++++--- .../com/arflix/tv/util/DownloadUtils.kt | 7 + .../arflix/tv/util/SubtitleLanguageMatcher.kt | 160 ++++++ .../com/arflix/tv/worker/DownloadWorker.kt | 245 +++++++++ 26 files changed, 3142 insertions(+), 217 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/data/db/ArflixDatabase.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/db/DownloadDao.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/db/DownloadEntity.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/DownloadsRepository.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/di/DatabaseModule.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/components/DownloadActionsSheet.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/components/DownloadSheet.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/screens/downloads/DownloadedEpisodesScreen.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/screens/downloads/DownloadsTab.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/screens/downloads/DownloadsViewModel.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/util/DownloadUtils.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/util/SubtitleLanguageMatcher.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/worker/DownloadWorker.kt 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..82fc9318 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/DownloadSheet.kt @@ -0,0 +1,499 @@ +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.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) +} + +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() } + 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 = "No direct download sources available", + 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..87a54172 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,7 @@ fun DetailsScreen( similarIndex = 0 isSidebarFocused = false viewModel.loadDetails(mediaType, mediaId, initialSeason, initialEpisode) + if (isMobile) viewModel.startObservingDownloads(mediaId, mediaType) } LaunchedEffect(uiState.episodes.size, uiState.totalSeasons, uiState.cast.size, uiState.reviews.size, uiState.similar.size) { @@ -875,9 +886,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.movieDownload else null, + onMovieDownloadClick = if (isMobile && 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 && 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 && 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 +1011,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 +1076,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 +1106,20 @@ fun DetailsScreen( onDismiss = { showEpisodeContextMenu = false contextMenuEpisode = null - } + }, + isDownloaded = isEpDownloaded, + onDownload = if (isMobile && 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 && existingDownload != null) ({ + showEpisodeContextMenu = false + viewModel.deleteEpisodeDownload(existingDownload.id) + }) else null ) } @@ -1189,14 +1303,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 +1601,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 +1778,8 @@ private fun DetailsContent( episode = episode, isFocused = false, spoilerBlurEnabled = spoilerBlurEnabled, - onClick = { onEpisodeClick(index) } + onClick = { onEpisodeClick(index) }, + onLongClick = onEpisodeLongClick?.let { cb -> { cb(index) } } ) } } @@ -3054,7 +3278,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 +3521,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 +3586,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..d758998b 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,16 @@ 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 ) data class StreamingServiceUi( @@ -178,7 +187,8 @@ 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 ) : ViewModel() { companion object { @@ -197,6 +207,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 +217,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 +276,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 +319,9 @@ class DetailsViewModel @Inject constructor( null }, autoPlaySingleSource = autoPlaySingleSource, - autoPlayMinQuality = autoPlayMinQuality + autoPlayMinQuality = autoPlayMinQuality, + preferredSubtitleLang = preferredSubtitleLang, + secondarySubtitleLang = secondarySubtitleLang ) fun logDetailsLoadFailure(label: String, throwable: Throwable) { @@ -1322,6 +1339,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 +1395,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 +2533,65 @@ class DetailsViewModel @Inject constructor( ) prewarmVisibleStreams(mergedStreams) } + + // ── Downloads ───────────────────────────────────────────────────────────── + + private var downloadObserveJob: kotlinx.coroutines.Job? = null + + 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>() {}.type + Gson().fromJson>(headersJson, type) ?: emptyMap() + }.getOrElse { emptyMap() } + } else emptyMap() + + if (downloadId < 0) return Result.failure() + + val downloadsDir = repository.downloadsDir().also { it.mkdirs() } + val urlPath = streamUrl.substringBefore('?').substringBefore('#') + val ext = urlPath.substringAfterLast('.', "").take(4).filter { it.isLetterOrDigit() } + .ifEmpty { "mp4" } + val title = inputData.getString(KEY_TITLE).orEmpty() + val season = inputData.getInt(KEY_SEASON, -1).takeIf { it > 0 } + val episode = inputData.getInt(KEY_EPISODE, -1).takeIf { it > 0 } + val baseName = buildFilename(title, season, episode, downloadId) + val videoFile = File(downloadsDir, "$baseName.$ext") + + return try { + runCatching { setForeground(buildForegroundInfo(downloadId, "Starting…", 0)) } + repository.updateStatusInternal(downloadId, DownloadStatus.DOWNLOADING) + + downloadVideo(streamUrl, videoFile, downloadId, userAgent, streamHeaders) + + if (isStopped) return Result.success() + + val subtitleResult = if (!subtitleUrl.isNullOrBlank()) { + downloadSubtitle(subtitleUrl, downloadId, downloadsDir, userAgent, streamHeaders) + } else null + + repository.markCompletedInternal( + id = downloadId, + localUri = videoFile.absolutePath, + fileSize = videoFile.length(), + subtitleLocalUri = subtitleResult?.first, + subtitleLang = subtitleResult?.second + ) + + notifyComplete(downloadId) + Result.success() + } catch (e: Exception) { + if (isStopped) return Result.success() + repository.markFailedInternal(downloadId) + if (runAttemptCount < 2) Result.retry() else Result.failure() + } + } + + private suspend fun downloadVideo( + url: String, + dest: File, + downloadId: Long, + userAgent: String, + headers: Map = emptyMap() + ) { + val existingBytes = if (dest.exists()) dest.length() else 0L + val request = buildRequest(url, userAgent, headers, rangeStart = existingBytes) + + okHttpClient.newCall(request).execute().use { response -> + // 206 Partial Content = server supports range request, resume from offset. + // 200 OK = no range support, must restart from zero. + val isResume = response.code == 206 && existingBytes > 0 + if (!response.isSuccessful) error("HTTP ${response.code}") + + val body = response.body ?: error("Empty body") + val contentLength = body.contentLength().takeIf { it > 0L } + val total = if (isResume && contentLength != null) existingBytes + contentLength + else contentLength + var downloaded = if (isResume) existingBytes else 0L + var lastUpdate = System.currentTimeMillis() + + // Append if resuming; overwrite (clearing stale partial data) if server ignored Range. + if (!isResume && dest.exists()) dest.delete() + + body.byteStream().use { input -> + FileOutputStream(dest, isResume).use { output -> + val buffer = ByteArray(BUFFER_SIZE) + while (!isStopped) { + val read = input.read(buffer) + if (read <= 0) break + output.write(buffer, 0, read) + downloaded += read + + val now = System.currentTimeMillis() + if (now - lastUpdate >= PROGRESS_UPDATE_INTERVAL_MS) { + lastUpdate = now + val progress = total?.let { + ((downloaded * 100) / it).toInt().coerceIn(0, 99) + } ?: 0 + repository.updateProgressInternal(downloadId, progress, downloaded) + runCatching { + setForeground( + buildForegroundInfo( + downloadId, + total?.let { "$progress%" } ?: formatBytes(downloaded), + progress + ) + ) + } + } + } + output.flush() + } + } + } + } + + private fun downloadSubtitle( + url: String, + downloadId: Long, + dir: File, + userAgent: String, + headers: Map = emptyMap() + ): Pair? = runCatching { + val ext = url.substringAfterLast('.', "srt").take(4).filter { it.isLetterOrDigit() } + val file = File(dir, "subtitle_${downloadId}.$ext") + val request = buildRequest(url, userAgent, headers) + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@runCatching null + val body = response.body ?: return@runCatching null + body.byteStream().use { input -> + FileOutputStream(file).use { output -> + input.copyTo(output) + output.flush() + } + } + } + file.absolutePath to (url.substringAfterLast('/').substringBefore('.')) + }.getOrNull() + + private fun buildRequest( + url: String, + userAgent: String, + headers: Map = emptyMap(), + rangeStart: Long = 0L + ): Request { + val builder = Request.Builder().url(url) + if (userAgent.isNotBlank()) builder.header("User-Agent", userAgent) + headers.forEach { (key, value) -> if (key.isNotBlank()) builder.header(key, value) } + if (rangeStart > 0) builder.header("Range", "bytes=$rangeStart-") + return builder.build() + } + + private fun buildForegroundInfo(id: Long, text: String, progress: Int): ForegroundInfo { + val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle("Downloading") + .setContentText(text) + .setProgress(100, progress, progress == 0) + .setOngoing(true) + .setSilent(true) + .build() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo( + (10000 + id).toInt(), + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + ForegroundInfo((10000 + id).toInt(), notification) + } + } + + private fun notifyComplete(id: Long) { + val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) + as NotificationManager + val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentTitle("Download complete") + .setAutoCancel(true) + .build() + nm.notify((10000 + id).toInt(), notification) + } + + private fun buildFilename(title: String, season: Int?, episode: Int?, fallbackId: Long): String { + val safe = title.replace(Regex("[^A-Za-z0-9 ]"), "").trim().replace(" ", ".") + val base = safe.ifEmpty { "download_$fallbackId" } + val episodePart = if (season != null && episode != null) { + ".S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}" + } else "" + return "$base$episodePart" + } +} From b6266b589a1c97b25f2c4898dcd62bc8b8f9fd8b Mon Sep 17 00:00:00 2001 From: chill pill 244 Date: Mon, 1 Jun 2026 18:59:27 -0700 Subject: [PATCH 2/2] fix(downloads): restrict downloads to local servers and hide when unconfigured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Limits the download stream picker to streams from user-controlled local servers (Jellyfin, Emby, Plex) to comply with Play Store policy. IPTV, Stremio addon, and Debrid streams are filtered out of the picker — the full infrastructure remains in place so support can be expanded later by removing the isLocalServerStream() guard. Also injects HomeServerRepository into DetailsViewModel and observes the connections flow to surface hasHomeServer in UiState. The Download button on the details screen and the Download option in the episode context menu are only shown when the user has a usable Jellyfin/Emby/Plex connection set up. Co-Authored-By: Claude Sonnet 4.6 --- .../arflix/tv/ui/components/DownloadSheet.kt | 10 ++++++++-- .../tv/ui/screens/details/DetailsScreen.kt | 17 ++++++++++------- .../tv/ui/screens/details/DetailsViewModel.kt | 18 ++++++++++++++++-- 3 files changed, 34 insertions(+), 11 deletions(-) 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 index 82fc9318..7cef28c7 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/DownloadSheet.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/DownloadSheet.kt @@ -52,6 +52,7 @@ 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 @@ -67,6 +68,11 @@ private fun StreamSource.isDirectDownloadable(): Boolean { 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 @@ -95,7 +101,7 @@ fun DownloadSheet( onConfirm: (stream: StreamSource, subtitle: Subtitle?) -> Unit, onDismiss: () -> Unit ) { - val downloadable = streams.filter { it.isDirectDownloadable() } + 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 @@ -263,7 +269,7 @@ fun DownloadSheet( contentAlignment = Alignment.Center ) { Text( - text = "No direct download sources available", + text = "Downloads are only available for your local server (Jellyfin, Emby, or Plex)", style = ArflixTypography.body, color = TextSecondary ) 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 87a54172..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 @@ -282,7 +282,10 @@ fun DetailsScreen( similarIndex = 0 isSidebarFocused = false viewModel.loadDetails(mediaType, mediaId, initialSeason, initialEpisode) - if (isMobile) viewModel.startObservingDownloads(mediaId, mediaType) + if (isMobile) { + viewModel.startObservingDownloads(mediaId, mediaType) + viewModel.startObservingHomeServer() + } } LaunchedEffect(uiState.episodes.size, uiState.totalSeasons, uiState.cast.size, uiState.reviews.size, uiState.similar.size) { @@ -897,8 +900,8 @@ fun DetailsScreen( onCastClick = onCastClickRemembered, onSimilarClick = onSimilarClickRemembered, onCollectionClick = onCollectionClickRemembered, - movieDownload = if (isMobile) uiState.movieDownload else null, - onMovieDownloadClick = if (isMobile && mediaType == MediaType.MOVIE) ({ + 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 -> { @@ -917,7 +920,7 @@ fun DetailsScreen( else -> Unit } }) else null, - episodeDownload = if (isMobile && mediaType == MediaType.TV) { + 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 @@ -925,7 +928,7 @@ fun DetailsScreen( tvDownloadLabel = if (mediaType == MediaType.TV && uiState.playSeason != null && uiState.playEpisode != null) { "S${uiState.playSeason}E${uiState.playEpisode}" } else null, - onEpisodeDownloadClick = if (isMobile && mediaType == MediaType.TV) ({ + 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 @@ -1108,7 +1111,7 @@ fun DetailsScreen( contextMenuEpisode = null }, isDownloaded = isEpDownloaded, - onDownload = if (isMobile && existingDownload == null) ({ + onDownload = if (isMobile && uiState.hasHomeServer && existingDownload == null) ({ showEpisodeContextMenu = false downloadSheetSeason = episode.seasonNumber downloadSheetEpisode = episode.episodeNumber @@ -1116,7 +1119,7 @@ fun DetailsScreen( viewModel.loadStreams(uiState.imdbId, episode.seasonNumber, episode.episodeNumber) showDownloadSheet = true }) else null, - onRemoveDownload = if (isMobile && existingDownload != null) ({ + onRemoveDownload = if (isMobile && uiState.hasHomeServer && existingDownload != null) ({ showEpisodeContextMenu = false viewModel.deleteEpisodeDownload(existingDownload.id) }) else null 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 d758998b..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 @@ -104,7 +104,8 @@ data class DetailsUiState( val collectionPosterPath: String? = null, // Downloads (mobile only) val episodeDownloads: Map = emptyMap(), - val movieDownload: DownloadEntity? = null + val movieDownload: DownloadEntity? = null, + val hasHomeServer: Boolean = false ) data class StreamingServiceUi( @@ -188,7 +189,8 @@ class DetailsViewModel @Inject constructor( private val watchlistRepository: WatchlistRepository, private val cloudSyncRepository: CloudSyncRepository, private val launcherContinueWatchingRepository: LauncherContinueWatchingRepository, - private val downloadsRepository: DownloadsRepository + private val downloadsRepository: DownloadsRepository, + private val homeServerRepository: HomeServerRepository ) : ViewModel() { companion object { @@ -2537,6 +2539,18 @@ class DetailsViewModel @Inject constructor( // ── 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()