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