Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
8 changes: 8 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

<!-- TV Features (not required so the same APK installs on phones/tablets) -->
<uses-feature
Expand Down Expand Up @@ -129,6 +132,11 @@
</intent-filter>
</receiver>

<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />

</application>

</manifest>
10 changes: 10 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 25 additions & 2 deletions app/src/main/kotlin/com/arflix/tv/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -654,6 +675,8 @@ fun ArflixApp(
launchSingleTop = true
}
},
activeDownloadProgress = activeDownloadProgress,
hasAnyDownloads = hasAnyDownloads,
modifier = Modifier.fillMaxWidth()
)
}
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/data/db/ArflixDatabase.kt
Original file line number Diff line number Diff line change
@@ -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"
}
}
64 changes: 64 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/data/db/DownloadDao.kt
Original file line number Diff line number Diff line change
@@ -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<List<DownloadEntity>>

@Query("SELECT * FROM downloads WHERE tmdb_id = :tmdbId ORDER BY season, episode")
fun observeByTmdbId(tmdbId: Int): Flow<List<DownloadEntity>>

@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<DownloadEntity>

@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)
}
40 changes: 40 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/data/db/DownloadEntity.kt
Original file line number Diff line number Diff line change
@@ -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<String,String> of stream-specific request headers (Referer, Authorization, etc.)
@ColumnInfo(name = "headers") val headers: String? = null
)
Loading
Loading