diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt index 2a30c309..4dc10c27 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt @@ -1,20 +1,24 @@ package com.nuvio.app.features.player +import android.content.Context import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultHttpDataSource import com.nuvio.app.features.trailer.YoutubeChunkedDataSourceFactory internal object PlatformPlaybackDataSourceFactory { fun create( + context: Context, defaultRequestHeaders: Map, defaultResponseHeaders: Map, useYoutubeChunkedPlayback: Boolean, ): DataSource.Factory { - val baseFactory: DataSource.Factory = if (useYoutubeChunkedPlayback) { + val networkFactory: DataSource.Factory = if (useYoutubeChunkedPlayback) { YoutubeChunkedDataSourceFactory(defaultRequestHeaders = defaultRequestHeaders) } else { DefaultHttpDataSource.Factory().setDefaultRequestProperties(defaultRequestHeaders) } + val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, networkFactory) return if (defaultResponseHeaders.isEmpty()) { baseFactory } else { diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 06eec0d5..a4b48672 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -35,6 +35,10 @@ android:path="/trakt" /> + + diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 12f5b3f7..822df483 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -13,6 +13,9 @@ import com.nuvio.app.core.deeplink.handleAppUrl import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner import com.nuvio.app.features.addons.AddonStorage import com.nuvio.app.features.collection.CollectionStorage +import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform +import com.nuvio.app.features.downloads.DownloadsPlatformDownloader +import com.nuvio.app.features.downloads.DownloadsStorage import com.nuvio.app.features.library.LibraryStorage import com.nuvio.app.features.details.MetaScreenSettingsStorage import com.nuvio.app.features.home.HomeCatalogSettingsStorage @@ -69,6 +72,9 @@ class MainActivity : ComponentActivity() { StreamLinkCacheStorage.initialize(applicationContext) PluginStorage.initialize(applicationContext) CollectionStorage.initialize(applicationContext) + DownloadsStorage.initialize(applicationContext) + DownloadsPlatformDownloader.initialize(applicationContext) + DownloadsLiveStatusPlatform.initialize(applicationContext) PlatformLocalAccountDataCleaner.initialize(applicationContext) EpisodeReleaseNotificationPlatform.initialize(applicationContext) EpisodeReleaseNotificationPlatform.bindActivity(this) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.android.kt new file mode 100644 index 00000000..2a9a1c8e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.android.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.downloads + +internal actual object DownloadsClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.android.kt new file mode 100644 index 00000000..1aab51c7 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.android.kt @@ -0,0 +1,258 @@ +package com.nuvio.app.features.downloads + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.nuvio.app.core.deeplink.buildDownloadsDeepLinkUrl +import kotlin.math.abs + +internal actual object DownloadsLiveStatusPlatform { + private const val channelId = "downloads_live_status" + private const val channelName = "Downloads" + private const val channelDescription = "Shows live download progress and controls." + private const val notificationsPrefName = "nuvio_download_live_notifications" + private const val trackedDownloadIdsKey = "tracked_download_ids" + + private var appContext: Context? = null + private val lastRenderStateById = mutableMapOf() + + fun initialize(context: Context) { + appContext = context.applicationContext + ensureNotificationChannel() + } + + actual fun onItemsChanged(items: List) { + val context = appContext ?: return + if (!canPostNotifications(context)) return + + val manager = NotificationManagerCompat.from(context) + val trackedBefore = preferences(context) + .getStringSet(trackedDownloadIdsKey, emptySet()) + .orEmpty() + .toMutableSet() + + val activeItems = items.filter { item -> + item.status == DownloadStatus.Downloading || + item.status == DownloadStatus.Paused || + item.status == DownloadStatus.Failed + } + + val trackedNow = mutableSetOf() + activeItems.forEach { item -> + val renderState = RenderState( + status = item.status, + progressPercent = progressPercent(item), + downloadedBucket = item.downloadedBytes / (512L * 1024L), + totalBytes = item.totalBytes, + errorMessage = item.errorMessage, + ) + + val existingState = lastRenderStateById[item.id] + if (existingState == renderState) { + trackedNow += item.id + return@forEach + } + + manager.notify(notificationId(item.id), buildNotification(context, item)) + lastRenderStateById[item.id] = renderState + trackedNow += item.id + } + + val staleIds = trackedBefore - trackedNow + staleIds.forEach { downloadId -> + manager.cancel(notificationId(downloadId)) + lastRenderStateById.remove(downloadId) + } + + preferences(context) + .edit() + .putStringSet(trackedDownloadIdsKey, trackedNow) + .apply() + } + + private fun buildNotification(context: Context, item: DownloadItem): android.app.Notification { + val subtitle = buildSubtitle(item) + val launchIntent = Intent(context, com.nuvio.app.MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = android.net.Uri.parse(buildDownloadsDeepLinkUrl()) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val launchPendingIntent = PendingIntent.getActivity( + context, + notificationId(item.id), + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val notificationBuilder = NotificationCompat.Builder(context, channelId) + .setSmallIcon(com.nuvio.app.R.drawable.ic_notification_small) + .setContentTitle(item.title) + .setContentText(subtitle) + .setStyle(NotificationCompat.BigTextStyle().bigText(subtitle)) + .setOnlyAlertOnce(true) + .setContentIntent(launchPendingIntent) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + + when (item.status) { + DownloadStatus.Downloading -> { + notificationBuilder + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .addAction( + 0, + "Pause", + buildActionPendingIntent( + context = context, + action = DownloadsNotificationActionReceiver.actionPause, + downloadId = item.id, + ), + ) + + val progress = progressPercent(item) + if (progress >= 0) { + notificationBuilder.setProgress(100, progress, false) + } else { + notificationBuilder.setProgress(100, 0, true) + } + } + + DownloadStatus.Paused, + DownloadStatus.Failed, + DownloadStatus.Completed, + -> { + notificationBuilder + .setOngoing(false) + .setAutoCancel(false) + .setPriority( + if (item.status == DownloadStatus.Failed) { + NotificationCompat.PRIORITY_DEFAULT + } else { + NotificationCompat.PRIORITY_LOW + }, + ) + .setProgress(0, 0, false) + .addAction( + 0, + "Resume", + buildActionPendingIntent( + context = context, + action = DownloadsNotificationActionReceiver.actionResume, + downloadId = item.id, + ), + ) + } + } + + return notificationBuilder.build() + } + + private fun buildSubtitle(item: DownloadItem): String { + val detail = item.displaySubtitle + return when (item.status) { + DownloadStatus.Downloading -> { + val downloaded = formatBytes(item.downloadedBytes) + val total = item.totalBytes?.let(::formatBytes) + if (total != null) { + "Downloading $detail • $downloaded / $total" + } else { + "Downloading $detail • $downloaded" + } + } + + DownloadStatus.Paused -> "Paused $detail" + DownloadStatus.Failed -> item.errorMessage?.takeIf { it.isNotBlank() } ?: "Download failed" + DownloadStatus.Completed -> "Download completed" + } + } + + private fun formatBytes(bytes: Long): String { + val safe = bytes.coerceAtLeast(0L).toDouble() + val units = arrayOf("B", "KB", "MB", "GB", "TB") + var value = safe + var unitIndex = 0 + while (value >= 1024.0 && unitIndex < units.lastIndex) { + value /= 1024.0 + unitIndex += 1 + } + return if (unitIndex == 0) { + "${value.toLong()} ${units[unitIndex]}" + } else { + "${"%.1f".format(value)} ${units[unitIndex]}" + } + } + + private fun progressPercent(item: DownloadItem): Int { + val total = item.totalBytes?.takeIf { it > 0L } ?: return -1 + return ((item.downloadedBytes.toDouble() / total.toDouble()) * 100.0) + .toInt() + .coerceIn(0, 100) + } + + private fun buildActionPendingIntent( + context: Context, + action: String, + downloadId: String, + ): PendingIntent { + val intent = Intent(context, DownloadsNotificationActionReceiver::class.java).apply { + this.action = action + putExtra(DownloadsNotificationActionReceiver.extraDownloadId, downloadId) + } + return PendingIntent.getBroadcast( + context, + notificationId("$action:$downloadId"), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun ensureNotificationChannel() { + val context = appContext ?: return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + ?: return + if (manager.getNotificationChannel(channelId) != null) return + + manager.createNotificationChannel( + NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW).apply { + description = channelDescription + }, + ) + } + + private fun canPostNotifications(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permissionState = ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) + if (permissionState != PackageManager.PERMISSION_GRANTED) { + return false + } + } + return NotificationManagerCompat.from(context).areNotificationsEnabled() + } + + private fun preferences(context: Context) = + context.getSharedPreferences(notificationsPrefName, Context.MODE_PRIVATE) + + private fun notificationId(downloadId: String): Int = abs(downloadId.hashCode()) + + private data class RenderState( + val status: DownloadStatus, + val progressPercent: Int, + val downloadedBucket: Long, + val totalBytes: Long?, + val errorMessage: String?, + ) +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsNotificationActionReceiver.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsNotificationActionReceiver.kt new file mode 100644 index 00000000..be7b3c66 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsNotificationActionReceiver.kt @@ -0,0 +1,29 @@ +package com.nuvio.app.features.downloads + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class DownloadsNotificationActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + val action = intent?.action ?: return + val downloadId = intent.getStringExtra(extraDownloadId)?.trim().orEmpty() + if (downloadId.isBlank()) return + + DownloadsStorage.initialize(context.applicationContext) + DownloadsPlatformDownloader.initialize(context.applicationContext) + DownloadsLiveStatusPlatform.initialize(context.applicationContext) + DownloadsRepository.ensureLoaded() + + when (action) { + actionPause -> DownloadsRepository.pauseDownload(downloadId) + actionResume -> DownloadsRepository.resumeDownload(downloadId) + } + } + + companion object { + const val actionPause = "com.nuvio.app.downloads.action.PAUSE" + const val actionResume = "com.nuvio.app.downloads.action.RESUME" + const val extraDownloadId = "download_id" + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt new file mode 100644 index 00000000..0d3b0724 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt @@ -0,0 +1,136 @@ +package com.nuvio.app.features.downloads + +import android.content.Context +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import okhttp3.Call +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.FileOutputStream +import java.net.URI +import java.util.concurrent.TimeUnit + +private val downloadHttpClient = OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + .build() + +internal actual object DownloadsPlatformDownloader { + private var appContext: Context? = null + + fun initialize(context: Context) { + appContext = context.applicationContext + } + + actual fun start( + request: DownloadPlatformRequest, + onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, + onSuccess: (localFileUri: String, totalBytes: Long?) -> Unit, + onFailure: (message: String) -> Unit, + ): DownloadsTaskHandle { + val job = SupervisorJob() + val scope = CoroutineScope(job + Dispatchers.IO) + var call: Call? = null + + scope.launch { + val context = appContext + if (context == null) { + onFailure("Download system is not initialized") + return@launch + } + + val downloadsDir = File(context.filesDir, "downloads").apply { mkdirs() } + val destination = File(downloadsDir, request.destinationFileName) + val tempFile = File(downloadsDir, "${request.destinationFileName}.part") + if (tempFile.exists()) tempFile.delete() + + val requestBuilder = Request.Builder().url(request.sourceUrl) + request.sourceHeaders.forEach { (key, value) -> + requestBuilder.header(key, value) + } + val httpRequest = requestBuilder.get().build() + call = downloadHttpClient.newCall(httpRequest) + + try { + call?.execute()?.use { response -> + if (!response.isSuccessful) { + error("Request failed with HTTP ${response.code}") + } + val body = response.body ?: error("Empty response body") + val totalBytes = body.contentLength().takeIf { it > 0L } + var downloadedBytes = 0L + + body.byteStream().use { input -> + FileOutputStream(tempFile, false).use { output -> + val buffer = ByteArray(16 * 1024) + while (true) { + ensureActive() + val read = input.read(buffer) + if (read <= 0) break + output.write(buffer, 0, read) + downloadedBytes += read.toLong() + onProgress(downloadedBytes, totalBytes) + } + output.flush() + } + } + + if (destination.exists()) { + destination.delete() + } + if (!tempFile.renameTo(destination)) { + tempFile.copyTo(destination, overwrite = true) + tempFile.delete() + } + + val finalSize = destination.length() + onSuccess(destination.toURI().toString(), totalBytes ?: finalSize) + } + } catch (_: CancellationException) { + tempFile.delete() + } catch (error: Throwable) { + tempFile.delete() + onFailure(error.message ?: "Download failed") + } + } + + job.invokeOnCompletion { + call?.cancel() + } + + return AndroidDownloadsTaskHandle(job) + } + + actual fun removeFile(localFileUri: String?): Boolean { + if (localFileUri.isNullOrBlank()) return false + val file = localFileUri.toLocalFileOrNull() ?: return false + return runCatching { file.delete() }.getOrDefault(false) + } +} + +private class AndroidDownloadsTaskHandle( + private val job: Job, +) : DownloadsTaskHandle { + override fun cancel() { + job.cancel() + } +} + +private fun String.toLocalFileOrNull(): File? { + return runCatching { + if (startsWith("file:")) { + File(URI(this)) + } else { + File(this) + } + }.getOrNull() +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.android.kt new file mode 100644 index 00000000..01ad3280 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.android.kt @@ -0,0 +1,26 @@ +package com.nuvio.app.features.downloads + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object DownloadsStorage { + private const val preferencesName = "nuvio_downloads" + private const val payloadKey = "downloads_payload" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadPayload(): String? = + preferences?.getString(ProfileScopedKey.of(payloadKey), null) + + actual fun savePayload(payload: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(payloadKey), payload) + ?.apply() + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index a1421720..cf975fa9 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -128,6 +128,7 @@ actual fun PlatformPlayerSurface( .setTsExtractorTimestampSearchBytes(1500 * TsExtractor.TS_PACKET_SIZE) val dataSourceFactory = PlatformPlaybackDataSourceFactory.create( + context = context, defaultRequestHeaders = sanitizedSourceHeaders, defaultResponseHeaders = sanitizedSourceResponseHeaders, useYoutubeChunkedPlayback = useYoutubeChunkedPlayback, diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt index 0935a904..d5d312db 100644 --- a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt @@ -1,16 +1,20 @@ package com.nuvio.app.features.player +import android.content.Context import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultHttpDataSource internal object PlatformPlaybackDataSourceFactory { fun create( + context: Context, defaultRequestHeaders: Map, defaultResponseHeaders: Map, useYoutubeChunkedPlayback: Boolean, ): DataSource.Factory { - val baseFactory = DefaultHttpDataSource.Factory() + val httpFactory = DefaultHttpDataSource.Factory() .setDefaultRequestProperties(defaultRequestHeaders) + val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, httpFactory) return if (defaultResponseHeaders.isEmpty()) { baseFactory } else { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 1cb1bd51..cf9a17c2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -88,6 +88,8 @@ import com.nuvio.app.features.auth.AuthScreen import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.catalog.CatalogScreen import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL +import com.nuvio.app.features.downloads.DownloadsRepository +import com.nuvio.app.features.downloads.DownloadsScreen import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsScreen import com.nuvio.app.features.details.MetaPerson @@ -192,6 +194,9 @@ object MetaScreenSettingsRoute @Serializable object ContinueWatchingSettingsRoute +@Serializable +object DownloadsSettingsRoute + @Serializable object AddonsSettingsRoute @@ -486,61 +491,150 @@ private fun MainAppContent( AppDeepLinkRepository.markConsumed(deepLink) } + AppDeepLink.Downloads -> { + selectedTab = AppScreenTab.Settings + navController.navigate(DownloadsSettingsRoute) { + launchSingleTop = true + } + AppDeepLinkRepository.markConsumed(deepLink) + } + null -> Unit } } } + fun launchPlaybackWithDownloadPreference( + type: String, + videoId: String, + parentMetaId: String, + parentMetaType: String, + title: String, + logo: String?, + poster: String?, + background: String?, + seasonNumber: Int?, + episodeNumber: Int?, + episodeTitle: String?, + episodeThumbnail: String?, + pauseDescription: String?, + resumePositionMs: Long?, + resumeProgressFraction: Float?, + manualSelection: Boolean, + startFromBeginning: Boolean, + ) { + val targetResumePositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L) + val targetResumeProgressFraction = if (startFromBeginning) null else resumeProgressFraction + + if (!manualSelection) { + val downloadedItem = DownloadsRepository.findPlayableDownload( + parentMetaId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + videoId = videoId, + ) + val localSourceUrl = downloadedItem?.localFileUri + if (!localSourceUrl.isNullOrBlank()) { + val launchId = PlayerLaunchStore.put( + PlayerLaunch( + title = title, + sourceUrl = localSourceUrl, + sourceHeaders = emptyMap(), + sourceResponseHeaders = emptyMap(), + logo = logo, + poster = poster, + background = background, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + episodeThumbnail = episodeThumbnail, + streamTitle = downloadedItem.streamTitle.ifBlank { title }, + streamSubtitle = downloadedItem.streamSubtitle, + pauseDescription = pauseDescription, + providerName = downloadedItem.providerName.ifBlank { "Downloaded" }, + providerAddonId = downloadedItem.providerAddonId, + contentType = type, + videoId = videoId, + parentMetaId = parentMetaId, + parentMetaType = parentMetaType, + initialPositionMs = targetResumePositionMs, + initialProgressFraction = targetResumeProgressFraction, + ), + ) + navController.navigate(PlayerRoute(launchId = launchId)) + return + } + } + + val streamContextId = pauseDescription + ?.takeIf { it.isNotBlank() } + ?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) } + navController.navigate( + StreamRoute( + type = type, + videoId = videoId, + parentMetaId = parentMetaId, + parentMetaType = parentMetaType, + title = title, + logo = logo, + poster = poster, + background = background, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + episodeThumbnail = episodeThumbnail, + streamContextId = streamContextId, + resumePositionMs = if (startFromBeginning) 0L else resumePositionMs, + resumeProgressFraction = targetResumeProgressFraction, + manualSelection = manualSelection, + startFromBeginning = startFromBeginning, + ), + ) + } + val onPlay: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, String?, Long?) -> Unit = { type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, pauseDescription, resumePositionMs -> - val streamContextId = pauseDescription - ?.takeIf { it.isNotBlank() } - ?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) } - navController.navigate( - StreamRoute( - type = type, - videoId = videoId, - parentMetaId = parentMetaId, - parentMetaType = parentMetaType, - title = title, - logo = logo, - poster = poster, - background = background, - seasonNumber = seasonNumber, - episodeNumber = episodeNumber, - episodeTitle = episodeTitle, - episodeThumbnail = episodeThumbnail, - streamContextId = streamContextId, - resumePositionMs = resumePositionMs, - resumeProgressFraction = null, - ) + launchPlaybackWithDownloadPreference( + type = type, + videoId = videoId, + parentMetaId = parentMetaId, + parentMetaType = parentMetaType, + title = title, + logo = logo, + poster = poster, + background = background, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + episodeThumbnail = episodeThumbnail, + pauseDescription = pauseDescription, + resumePositionMs = resumePositionMs, + resumeProgressFraction = null, + manualSelection = false, + startFromBeginning = false, ) } val onPlayManually: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, String?, Long?) -> Unit = { type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, pauseDescription, resumePositionMs -> - val streamContextId = pauseDescription - ?.takeIf { it.isNotBlank() } - ?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) } - navController.navigate( - StreamRoute( - type = type, - videoId = videoId, - parentMetaId = parentMetaId, - parentMetaType = parentMetaType, - title = title, - logo = logo, - poster = poster, - background = background, - seasonNumber = seasonNumber, - episodeNumber = episodeNumber, - episodeTitle = episodeTitle, - episodeThumbnail = episodeThumbnail, - streamContextId = streamContextId, - resumePositionMs = resumePositionMs, - resumeProgressFraction = null, - manualSelection = true, - ) + launchPlaybackWithDownloadPreference( + type = type, + videoId = videoId, + parentMetaId = parentMetaId, + parentMetaType = parentMetaType, + title = title, + logo = logo, + poster = poster, + background = background, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + episodeThumbnail = episodeThumbnail, + pauseDescription = pauseDescription, + resumePositionMs = resumePositionMs, + resumeProgressFraction = null, + manualSelection = true, + startFromBeginning = false, ) } @@ -575,29 +669,24 @@ private fun MainAppContent( } val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning -> - val streamContextId = item.pauseDescription - ?.takeIf { it.isNotBlank() } - ?.let { StreamContextStore.put(StreamContext(pauseDescription = it)) } - navController.navigate( - StreamRoute( - type = item.parentMetaType, - videoId = item.videoId, - parentMetaId = item.parentMetaId, - parentMetaType = item.parentMetaType, - title = item.title, - logo = item.logo, - poster = item.poster, - background = item.background, - seasonNumber = item.seasonNumber, - episodeNumber = item.episodeNumber, - episodeTitle = item.episodeTitle, - episodeThumbnail = item.episodeThumbnail, - streamContextId = streamContextId, - resumePositionMs = if (startFromBeginning) 0L else item.resumePositionMs, - resumeProgressFraction = if (startFromBeginning) null else item.resumeProgressFraction, - manualSelection = manualSelection, - startFromBeginning = startFromBeginning, - ), + launchPlaybackWithDownloadPreference( + type = item.parentMetaType, + videoId = item.videoId, + parentMetaId = item.parentMetaId, + parentMetaType = item.parentMetaType, + title = item.title, + logo = item.logo, + poster = item.poster, + background = item.background, + seasonNumber = item.seasonNumber, + episodeNumber = item.episodeNumber, + episodeTitle = item.episodeTitle, + episodeThumbnail = item.episodeThumbnail, + pauseDescription = item.pauseDescription, + resumePositionMs = item.resumePositionMs, + resumeProgressFraction = item.resumeProgressFraction, + manualSelection = manualSelection, + startFromBeginning = startFromBeginning, ) } @@ -710,6 +799,7 @@ private fun MainAppContent( onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) }, onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) }, onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) }, + onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) }, onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) }, onPluginsSettingsClick = { if (AppFeaturePolicy.pluginsEnabled) { @@ -1236,6 +1326,49 @@ private fun MainAppContent( onBack = onBack, ) } + composable { backStackEntry -> + val onBack = rememberGuardedPopBackStack( + navController = navController, + backStackEntry = backStackEntry, + ) + DownloadsScreen( + onBack = onBack, + onOpenDownload = { item -> + val sourceUrl = item.localFileUri ?: return@DownloadsScreen + val resumeEntry = item.videoId + .takeIf { it.isNotBlank() } + ?.let(WatchProgressRepository::progressForVideo) + ?.takeIf { it.isResumable } + + val launchId = PlayerLaunchStore.put( + PlayerLaunch( + title = item.title, + sourceUrl = sourceUrl, + sourceHeaders = emptyMap(), + sourceResponseHeaders = emptyMap(), + logo = item.logo, + poster = item.poster, + background = item.background, + seasonNumber = item.seasonNumber, + episodeNumber = item.episodeNumber, + episodeTitle = item.episodeTitle, + episodeThumbnail = item.episodeThumbnail, + streamTitle = item.streamTitle, + streamSubtitle = item.streamSubtitle, + providerName = item.providerName, + providerAddonId = item.providerAddonId, + contentType = item.contentType, + videoId = item.videoId, + parentMetaId = item.parentMetaId, + parentMetaType = item.parentMetaType, + initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L, + initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f }, + ), + ) + navController.navigate(PlayerRoute(launchId = launchId)) + }, + ) + } composable { backStackEntry -> val onBack = rememberGuardedPopBackStack( navController = navController, @@ -1511,6 +1644,7 @@ private fun AppTabHost( onHomescreenSettingsClick: () -> Unit = {}, onMetaScreenSettingsClick: () -> Unit = {}, onContinueWatchingSettingsClick: () -> Unit = {}, + onDownloadsSettingsClick: () -> Unit = {}, onAddonsSettingsClick: () -> Unit = {}, onPluginsSettingsClick: () -> Unit = {}, onAccountSettingsClick: () -> Unit = {}, @@ -1559,6 +1693,7 @@ private fun AppTabHost( onHomescreenClick = onHomescreenSettingsClick, onMetaScreenClick = onMetaScreenSettingsClick, onContinueWatchingClick = onContinueWatchingSettingsClick, + onDownloadsClick = onDownloadsSettingsClick, onAddonsClick = onAddonsSettingsClick, onPluginsClick = onPluginsSettingsClick, onAccountClick = onAccountSettingsClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt index 6edb9e2b..e36e3934 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt @@ -12,6 +12,8 @@ sealed interface AppDeepLink { val type: String, val id: String, ) : AppDeepLink + + data object Downloads : AppDeepLink } object AppDeepLinkRepository { @@ -49,6 +51,8 @@ fun buildMetaDeepLinkUrl( append(id.trim().encodeURLParameter()) } +fun buildDownloadsDeepLinkUrl(): String = "nuvio://downloads" + private fun parseAppDeepLink(url: String): AppDeepLink? { val parsedUrl = runCatching { Url(url) }.getOrNull() ?: return null if (!parsedUrl.protocol.name.equals("nuvio", ignoreCase = true)) return null @@ -60,6 +64,8 @@ private fun parseAppDeepLink(url: String): AppDeepLink? { if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id) } + "downloads" -> AppDeepLink.Downloads + else -> null } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.kt new file mode 100644 index 00000000..a53d3d09 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.downloads + +internal expect object DownloadsClock { + fun nowEpochMs(): Long +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.kt new file mode 100644 index 00000000..e3214228 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.downloads + +internal expect object DownloadsLiveStatusPlatform { + fun onItemsChanged(items: List) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt new file mode 100644 index 00000000..05b1d0bf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt @@ -0,0 +1,101 @@ +package com.nuvio.app.features.downloads + +import kotlinx.serialization.Serializable + +@Serializable +enum class DownloadStatus { + Downloading, + Paused, + Completed, + Failed, +} + +@Serializable +data class DownloadItem( + val id: String, + val contentType: String, + val parentMetaId: String, + val parentMetaType: String, + val videoId: String, + val title: String, + val logo: String? = null, + val poster: String? = null, + val background: String? = null, + val seasonNumber: Int? = null, + val episodeNumber: Int? = null, + val episodeTitle: String? = null, + val episodeThumbnail: String? = null, + val streamTitle: String, + val streamSubtitle: String? = null, + val providerName: String, + val providerAddonId: String? = null, + val sourceUrl: String, + val sourceHeaders: Map = emptyMap(), + val sourceResponseHeaders: Map = emptyMap(), + val localFileUri: String? = null, + val fileName: String, + val status: DownloadStatus, + val downloadedBytes: Long = 0L, + val totalBytes: Long? = null, + val errorMessage: String? = null, + val createdAtEpochMs: Long, + val updatedAtEpochMs: Long, +) { + val isEpisode: Boolean + get() = seasonNumber != null && episodeNumber != null + + val isPlayable: Boolean + get() = status == DownloadStatus.Completed && !localFileUri.isNullOrBlank() + + val displaySubtitle: String + get() = if (isEpisode) { + buildString { + append("S") + append(seasonNumber) + append("E") + append(episodeNumber) + episodeTitle + ?.takeIf { it.isNotBlank() } + ?.let { + append(" • ") + append(it) + } + } + } else { + "Movie" + } + + val progressFraction: Float + get() { + val total = totalBytes?.takeIf { it > 0L } ?: return 0f + return (downloadedBytes.toDouble() / total.toDouble()) + .toFloat() + .coerceIn(0f, 1f) + } + + val logicalContentKey: String + get() = if (isEpisode) { + "${parentMetaId.trim()}|${seasonNumber ?: -1}|${episodeNumber ?: -1}" + } else { + "${parentMetaId.trim()}|movie" + } +} + +data class DownloadsUiState( + val items: List = emptyList(), +) { + val activeItems: List + get() = items.filter { it.status != DownloadStatus.Completed } + + val completedItems: List + get() = items.filter { it.status == DownloadStatus.Completed } +} + +enum class DownloadEnqueueResult( + val toastMessage: String, +) { + Started("Download started"), + Replaced("Replaced previous download"), + MissingUrl("No direct stream link available"), + UnsupportedFormat("Unsupported stream format for downloads"), +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt new file mode 100644 index 00000000..1d91ff46 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt @@ -0,0 +1,22 @@ +package com.nuvio.app.features.downloads + +internal data class DownloadPlatformRequest( + val sourceUrl: String, + val sourceHeaders: Map, + val destinationFileName: String, +) + +internal interface DownloadsTaskHandle { + fun cancel() +} + +internal expect object DownloadsPlatformDownloader { + fun start( + request: DownloadPlatformRequest, + onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, + onSuccess: (localFileUri: String, totalBytes: Long?) -> Unit, + onFailure: (message: String) -> Unit, + ): DownloadsTaskHandle + + fun removeFile(localFileUri: String?): Boolean +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt new file mode 100644 index 00000000..25b29dd7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt @@ -0,0 +1,487 @@ +package com.nuvio.app.features.downloads + +import com.nuvio.app.features.streams.StreamItem +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +object DownloadsRepository { + private val _uiState = MutableStateFlow(DownloadsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val activeHandles = mutableMapOf() + private var hasLoaded = false + private var nextDownloadOrdinal = 0L + + fun ensureLoaded() { + if (hasLoaded) return + loadFromDisk() + } + + fun onProfileChanged() { + loadFromDisk() + } + + fun clearLocalState() { + activeHandles.values.forEach(DownloadsTaskHandle::cancel) + activeHandles.clear() + hasLoaded = false + _uiState.value = DownloadsUiState() + notifyLiveStatusPlatform() + } + + fun findPlayableDownloadByVideoId(videoId: String?): DownloadItem? { + ensureLoaded() + val normalizedVideoId = videoId?.trim().orEmpty() + if (normalizedVideoId.isBlank()) return null + return _uiState.value.items.firstOrNull { item -> + item.videoId == normalizedVideoId && item.isPlayable && !item.localFileUri.isNullOrBlank() + } + } + + fun findPlayableDownload( + parentMetaId: String, + seasonNumber: Int? = null, + episodeNumber: Int? = null, + videoId: String? = null, + ): DownloadItem? { + ensureLoaded() + val items = _uiState.value.items + val normalizedParentMetaId = parentMetaId.trim() + + findPlayableDownloadByVideoId(videoId)?.let { return it } + + return if (seasonNumber != null && episodeNumber != null) { + items.firstOrNull { item -> + item.parentMetaId == normalizedParentMetaId && + item.seasonNumber == seasonNumber && + item.episodeNumber == episodeNumber && + item.isPlayable && + !item.localFileUri.isNullOrBlank() + } + } else { + items.firstOrNull { item -> + item.parentMetaId == normalizedParentMetaId && + item.seasonNumber == null && + item.episodeNumber == null && + item.isPlayable && + !item.localFileUri.isNullOrBlank() + } + } + } + + fun enqueueFromStream( + contentType: String, + videoId: String, + parentMetaId: String, + parentMetaType: String, + title: String, + logo: String?, + poster: String?, + background: String?, + seasonNumber: Int?, + episodeNumber: Int?, + episodeTitle: String?, + episodeThumbnail: String?, + stream: StreamItem, + ): DownloadEnqueueResult { + ensureLoaded() + + val sourceUrl = stream.directPlaybackUrl + ?.trim() + ?.takeIf { it.isNotBlank() } + ?: return DownloadEnqueueResult.MissingUrl + + if (!sourceUrl.isSupportedDownloadUrl()) { + return DownloadEnqueueResult.UnsupportedFormat + } + + val now = DownloadsClock.nowEpochMs() + val logicalKey = buildLogicalKey( + parentMetaId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) + + var replacedExisting = false + val currentItems = _uiState.value.items.toMutableList() + val existing = currentItems.firstOrNull { it.logicalContentKey == logicalKey } + if (existing != null) { + replacedExisting = true + activeHandles.remove(existing.id)?.cancel() + DownloadsPlatformDownloader.removeFile(existing.localFileUri) + currentItems.removeAll { it.id == existing.id } + } + + val downloadId = nextDownloadId(now) + val fileName = buildFileName( + title = title, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + fallbackTitle = stream.streamLabel, + sourceUrl = sourceUrl, + nowEpochMs = now, + ) + + val item = DownloadItem( + id = downloadId, + contentType = contentType, + parentMetaId = parentMetaId, + parentMetaType = parentMetaType, + videoId = videoId, + title = title, + logo = logo, + poster = poster, + background = background, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + episodeThumbnail = episodeThumbnail, + streamTitle = stream.streamLabel, + streamSubtitle = stream.streamSubtitle, + providerName = stream.addonName, + providerAddonId = stream.addonId, + sourceUrl = sourceUrl, + sourceHeaders = sanitizeRequestHeaders(stream.behaviorHints.proxyHeaders?.request), + sourceResponseHeaders = sanitizeResponseHeaders(stream.behaviorHints.proxyHeaders?.response), + localFileUri = null, + fileName = fileName, + status = DownloadStatus.Downloading, + downloadedBytes = 0L, + totalBytes = null, + errorMessage = null, + createdAtEpochMs = now, + updatedAtEpochMs = now, + ) + + currentItems.add(0, item) + publish(currentItems) + persist() + startDownload(item) + + return if (replacedExisting) { + DownloadEnqueueResult.Replaced + } else { + DownloadEnqueueResult.Started + } + } + + fun pauseDownload(downloadId: String) { + ensureLoaded() + val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return + if (item.status != DownloadStatus.Downloading) return + + activeHandles.remove(downloadId)?.cancel() + mutateItem(downloadId) { current -> + current.copy( + status = DownloadStatus.Paused, + updatedAtEpochMs = DownloadsClock.nowEpochMs(), + errorMessage = null, + ) + } + } + + fun resumeDownload(downloadId: String) { + ensureLoaded() + val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return + if (item.status != DownloadStatus.Paused && item.status != DownloadStatus.Failed) return + + val reset = item.copy( + status = DownloadStatus.Downloading, + downloadedBytes = 0L, + totalBytes = null, + errorMessage = null, + localFileUri = null, + updatedAtEpochMs = DownloadsClock.nowEpochMs(), + ) + + replaceItem(reset) + persist() + startDownload(reset) + } + + fun retryDownload(downloadId: String) { + resumeDownload(downloadId) + } + + fun cancelDownload(downloadId: String) { + ensureLoaded() + val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return + + activeHandles.remove(downloadId)?.cancel() + DownloadsPlatformDownloader.removeFile(item.localFileUri) + + publish(_uiState.value.items.filterNot { it.id == downloadId }) + persist() + } + + private fun loadFromDisk() { + hasLoaded = true + val payload = DownloadsStorage.loadPayload().orEmpty().trim() + if (payload.isEmpty()) { + _uiState.value = DownloadsUiState() + notifyLiveStatusPlatform() + return + } + + val normalized = DownloadsCodec.decodeItems(payload) + .map { item -> + if (item.status == DownloadStatus.Downloading) { + item.copy( + status = DownloadStatus.Paused, + errorMessage = null, + ) + } else { + item + } + } + .sortedByDescending { it.updatedAtEpochMs } + + _uiState.value = DownloadsUiState(normalized) + notifyLiveStatusPlatform() + } + + private fun startDownload(item: DownloadItem) { + val request = DownloadPlatformRequest( + sourceUrl = item.sourceUrl, + sourceHeaders = item.sourceHeaders, + destinationFileName = item.fileName, + ) + + val handle = DownloadsPlatformDownloader.start( + request = request, + onProgress = { downloadedBytes, totalBytes -> + mutateItem(item.id) { current -> + if (current.status != DownloadStatus.Downloading) { + current + } else { + current.copy( + downloadedBytes = downloadedBytes.coerceAtLeast(0L), + totalBytes = totalBytes?.takeIf { it > 0L }, + updatedAtEpochMs = DownloadsClock.nowEpochMs(), + errorMessage = null, + ) + } + } + }, + onSuccess = { localFileUri, totalBytes -> + activeHandles.remove(item.id) + mutateItem(item.id) { current -> + current.copy( + status = DownloadStatus.Completed, + localFileUri = localFileUri, + downloadedBytes = if (totalBytes != null && totalBytes > 0L) { + totalBytes + } else { + current.downloadedBytes + }, + totalBytes = totalBytes?.takeIf { it > 0L } ?: current.totalBytes, + errorMessage = null, + updatedAtEpochMs = DownloadsClock.nowEpochMs(), + ) + } + }, + onFailure = { message -> + activeHandles.remove(item.id) + mutateItem(item.id) { current -> + if (current.status != DownloadStatus.Downloading) { + current + } else { + current.copy( + status = DownloadStatus.Failed, + errorMessage = message.ifBlank { "Download failed" }, + updatedAtEpochMs = DownloadsClock.nowEpochMs(), + ) + } + } + }, + ) + + activeHandles[item.id] = handle + } + + private fun mutateItem(downloadId: String, transform: (DownloadItem) -> DownloadItem) { + var changed = false + val updated = _uiState.value.items.map { item -> + if (item.id == downloadId) { + changed = true + transform(item) + } else { + item + } + } + + if (changed) { + publish(updated) + persist() + } + } + + private fun replaceItem(item: DownloadItem) { + val updated = _uiState.value.items.map { existing -> + if (existing.id == item.id) item else existing + } + publish(updated) + } + + private fun publish(items: List) { + _uiState.value = DownloadsUiState( + items = items.sortedByDescending { it.updatedAtEpochMs }, + ) + notifyLiveStatusPlatform() + } + + private fun notifyLiveStatusPlatform() { + runCatching { + DownloadsLiveStatusPlatform.onItemsChanged(_uiState.value.items) + } + } + + private fun persist() { + DownloadsStorage.savePayload( + DownloadsCodec.encodeItems(_uiState.value.items), + ) + } + + private fun nextDownloadId(nowEpochMs: Long): String { + nextDownloadOrdinal += 1L + return buildString { + append(nowEpochMs.toString(36)) + append('_') + append(nextDownloadOrdinal.toString(36)) + } + } +} + +@Serializable +private data class StoredDownloadsPayload( + val items: List = emptyList(), +) + +private object DownloadsCodec { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + fun decodeItems(payload: String): List = + runCatching { + json.decodeFromString(payload).items + }.getOrDefault(emptyList()) + + fun encodeItems(items: Collection): String = + json.encodeToString( + StoredDownloadsPayload( + items = items.toList().sortedByDescending { it.updatedAtEpochMs }, + ), + ) +} + +private fun sanitizeRequestHeaders(headers: Map?): Map = + headers + .orEmpty() + .mapNotNull { (key, value) -> + val normalizedKey = key.trim() + val normalizedValue = value.trim() + if ( + normalizedKey.isBlank() || + normalizedValue.isBlank() || + normalizedKey.equals("Accept-Encoding", ignoreCase = true) || + normalizedKey.equals("Range", ignoreCase = true) + ) { + null + } else { + normalizedKey to normalizedValue + } + } + .toMap() + +private fun sanitizeResponseHeaders(headers: Map?): Map = + headers + .orEmpty() + .mapNotNull { (key, value) -> + val normalizedKey = key.trim() + val normalizedValue = value.trim() + if (normalizedKey.isBlank() || normalizedValue.isBlank()) { + null + } else { + normalizedKey to normalizedValue + } + } + .toMap() + +private fun buildLogicalKey( + parentMetaId: String, + seasonNumber: Int?, + episodeNumber: Int?, +): String = if (seasonNumber != null && episodeNumber != null) { + "${parentMetaId.trim()}|$seasonNumber|$episodeNumber" +} else { + "${parentMetaId.trim()}|movie" +} + +private fun buildFileName( + title: String, + seasonNumber: Int?, + episodeNumber: Int?, + episodeTitle: String?, + fallbackTitle: String, + sourceUrl: String, + nowEpochMs: Long, +): String { + val baseTitle = if (seasonNumber != null && episodeNumber != null) { + buildString { + append(title) + append(" S") + append(seasonNumber.toString().padStart(2, '0')) + append('E') + append(episodeNumber.toString().padStart(2, '0')) + if (!episodeTitle.isNullOrBlank()) { + append(' ') + append(episodeTitle) + } + } + } else { + title.ifBlank { fallbackTitle } + } + + val extension = sourceUrl.fileExtensionFromUrl() + return buildString { + append(baseTitle.sanitizeFileName().ifBlank { "download" }.take(92)) + append('_') + append(nowEpochMs.toString(36)) + append('.') + append(extension) + } +} + +private fun String.sanitizeFileName(): String = + trim().replace(Regex("[^A-Za-z0-9._ -]"), "_") + +private fun String.fileExtensionFromUrl(): String { + val withoutQuery = substringBefore('?').substringBefore('#') + val suffix = withoutQuery.substringAfterLast('.', missingDelimiterValue = "") + .lowercase() + .trim() + + return if (suffix.length in 2..5 && suffix.all { it.isLetterOrDigit() }) { + suffix + } else { + "mp4" + } +} + +private fun String.isSupportedDownloadUrl(): Boolean { + val normalized = trim().lowercase() + if (normalized.startsWith("magnet:")) return false + if (normalized.endsWith(".m3u8") || normalized.contains(".m3u8?")) return false + if (normalized.endsWith(".mpd") || normalized.contains(".mpd?")) return false + if (normalized.endsWith(".torrent") || normalized.contains(".torrent?")) return false + return normalized.startsWith("http://") || normalized.startsWith("https://") +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt new file mode 100644 index 00000000..7158b18d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt @@ -0,0 +1,431 @@ +package com.nuvio.app.features.downloads + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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 +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.ui.NuvioScreen +import com.nuvio.app.core.ui.NuvioScreenHeader + +@Composable +fun DownloadsScreen( + onBack: () -> Unit, + onOpenDownload: (DownloadItem) -> Unit, +) { + val uiState by remember { + DownloadsRepository.ensureLoaded() + DownloadsRepository.uiState + }.collectAsStateWithLifecycle() + + var selectedShowId by rememberSaveable { mutableStateOf(null) } + + val completedEpisodes = remember(uiState.items) { + uiState.completedItems + .filter { it.isEpisode } + .sortedByDescending { it.updatedAtEpochMs } + } + + val selectedShowTitle = remember(selectedShowId, completedEpisodes) { + selectedShowId?.let { showId -> + completedEpisodes.firstOrNull { it.parentMetaId == showId }?.title + } + } + + NuvioScreen { + stickyHeader { + NuvioScreenHeader( + title = if (selectedShowId == null) { + "Downloads" + } else { + selectedShowTitle ?: "Show Downloads" + }, + onBack = { + if (selectedShowId != null) { + selectedShowId = null + } else { + onBack() + } + }, + ) + } + + if (selectedShowId == null) { + downloadsRootContent( + uiState = uiState, + onOpenDownload = onOpenDownload, + onOpenShow = { showId -> selectedShowId = showId }, + ) + } else { + downloadsShowContent( + showId = selectedShowId.orEmpty(), + episodes = completedEpisodes, + onOpenDownload = onOpenDownload, + ) + } + } +} + +private fun LazyListScope.downloadsRootContent( + uiState: DownloadsUiState, + onOpenDownload: (DownloadItem) -> Unit, + onOpenShow: (String) -> Unit, +) { + val activeItems = uiState.activeItems + val completedMovies = uiState.completedItems.filterNot(DownloadItem::isEpisode) + val completedShows = uiState.completedItems + .filter(DownloadItem::isEpisode) + .groupBy { it.parentMetaId } + .mapNotNull { (_, episodes) -> + episodes.firstOrNull()?.let { first -> + first to episodes + } + } + .sortedBy { (item, _) -> item.title.lowercase() } + + if (activeItems.isNotEmpty()) { + item { + SectionTitle("ACTIVE") + } + items(activeItems.size) { index -> + val item = activeItems[index] + DownloadRow( + item = item, + onOpen = { onOpenDownload(item) }, + onPause = { DownloadsRepository.pauseDownload(item.id) }, + onResume = { DownloadsRepository.resumeDownload(item.id) }, + onRetry = { DownloadsRepository.retryDownload(item.id) }, + onDelete = { DownloadsRepository.cancelDownload(item.id) }, + ) + } + } + + if (completedMovies.isNotEmpty()) { + item { + SectionTitle("MOVIES") + } + items(completedMovies.size) { index -> + val item = completedMovies[index] + DownloadRow( + item = item, + onOpen = { onOpenDownload(item) }, + onPause = { DownloadsRepository.pauseDownload(item.id) }, + onResume = { DownloadsRepository.resumeDownload(item.id) }, + onRetry = { DownloadsRepository.retryDownload(item.id) }, + onDelete = { DownloadsRepository.cancelDownload(item.id) }, + ) + } + } + + if (completedShows.isNotEmpty()) { + item { + SectionTitle("SHOWS") + } + items(completedShows.size) { index -> + val (item, episodes) = completedShows[index] + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp) + .clickable { onOpenShow(item.parentMetaId) }, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = item.title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${episodes.size} downloaded episode${if (episodes.size == 1) "" else "s"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + if (uiState.items.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 40.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "No downloads yet", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +private fun LazyListScope.downloadsShowContent( + showId: String, + episodes: List, + onOpenDownload: (DownloadItem) -> Unit, +) { + val showEpisodes = episodes + .filter { it.parentMetaId == showId } + + val seasons = showEpisodes + .groupBy { it.seasonNumber ?: 0 } + .toList() + .sortedWith( + compareBy>> { (season, _) -> + if (season == 0) 0 else 1 + }.thenBy { (season, _) -> if (season == 0) 0 else season }, + ) + + if (seasons.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 40.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "No completed episodes", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + return + } + + seasons.forEach { (seasonNumber, entries) -> + val seasonTitle = if (seasonNumber == 0) { + "Specials" + } else { + "Season $seasonNumber" + } + item { + SectionTitle(seasonTitle) + } + + val sortedEpisodes = entries.sortedWith( + compareBy { it.episodeNumber ?: Int.MAX_VALUE } + .thenByDescending { it.updatedAtEpochMs }, + ) + + items(sortedEpisodes.size) { index -> + val item = sortedEpisodes[index] + DownloadRow( + item = item, + onOpen = { onOpenDownload(item) }, + onPause = { DownloadsRepository.pauseDownload(item.id) }, + onResume = { DownloadsRepository.resumeDownload(item.id) }, + onRetry = { DownloadsRepository.retryDownload(item.id) }, + onDelete = { DownloadsRepository.cancelDownload(item.id) }, + ) + } + } +} + +@Composable +private fun DownloadRow( + item: DownloadItem, + onOpen: () -> Unit, + onPause: () -> Unit, + onResume: () -> Unit, + onRetry: () -> Unit, + onDelete: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp) + .clickable(enabled = item.isPlayable, onClick = onOpen), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = item.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = item.displaySubtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = statusText(item), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + when (item.status) { + DownloadStatus.Downloading -> { + IconButton(onClick = onPause) { + Icon( + imageVector = Icons.Rounded.Pause, + contentDescription = "Pause", + ) + } + } + DownloadStatus.Paused -> { + IconButton(onClick = onResume) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = "Resume", + ) + } + } + DownloadStatus.Failed -> { + IconButton(onClick = onRetry) { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = "Retry", + ) + } + } + DownloadStatus.Completed -> { + IconButton(onClick = onOpen) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = "Play", + ) + } + } + } + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = "Delete", + ) + } + } + } + + if (item.status == DownloadStatus.Downloading) { + if (item.totalBytes != null && item.totalBytes > 0L) { + LinearProgressIndicator( + progress = item.progressFraction, + modifier = Modifier.fillMaxWidth(), + ) + } else { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} + +@Composable +private fun SectionTitle(title: String) { + Text( + text = title, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + ) +} + +private fun statusText(item: DownloadItem): String { + val size = if (item.totalBytes != null && item.totalBytes > 0L) { + "${formatBytes(item.downloadedBytes)} / ${formatBytes(item.totalBytes)}" + } else { + formatBytes(item.downloadedBytes) + } + + return when (item.status) { + DownloadStatus.Downloading -> "Downloading • $size" + DownloadStatus.Paused -> "Paused • $size" + DownloadStatus.Completed -> "Completed • ${formatBytes(item.totalBytes ?: item.downloadedBytes)}" + DownloadStatus.Failed -> item.errorMessage ?: "Failed" + } +} + +private fun formatBytes(bytes: Long): String { + if (bytes <= 0L) return "0 B" + val kib = 1024.0 + val mib = kib * 1024.0 + val gib = mib * 1024.0 + val value = bytes.toDouble() + return when { + value >= gib -> "${((value / gib) * 10.0).toInt() / 10.0} GB" + value >= mib -> "${((value / mib) * 10.0).toInt() / 10.0} MB" + value >= kib -> "${((value / kib) * 10.0).toInt() / 10.0} KB" + else -> "$bytes B" + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.kt new file mode 100644 index 00000000..4721842a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.downloads + +internal expect object DownloadsStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index e8a44966..d1ff0e02 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -40,6 +40,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.downloads.DownloadItem +import com.nuvio.app.features.downloads.DownloadsRepository import com.nuvio.app.features.player.skip.NextEpisodeCard import com.nuvio.app.features.player.skip.NextEpisodeInfo import com.nuvio.app.features.player.skip.PlayerNextEpisodeRules @@ -793,11 +795,70 @@ fun PlayerScreen( controlsVisible = true } + fun switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) { + val localFileUri = downloadItem.localFileUri ?: return + showNextEpisodeCard = false + showSourcesPanel = false + showEpisodesPanel = false + episodeStreamsPanelState = EpisodeStreamsPanelState() + nextEpisodeAutoPlayJob?.cancel() + nextEpisodeAutoPlaySearching = false + nextEpisodeAutoPlaySourceName = null + nextEpisodeAutoPlayCountdown = null + PlayerStreamsRepository.clearEpisodeStreams() + flushWatchProgress() + + val fallbackVideoId = buildPlaybackVideoId( + parentMetaId = parentMetaId, + seasonNumber = episode.season, + episodeNumber = episode.episode, + fallbackVideoId = episode.id, + ) + val resolvedVideoId = episode.id.takeIf { it.isNotBlank() } ?: fallbackVideoId + val epEntry = WatchProgressRepository.progressForVideo(resolvedVideoId) + ?.takeIf { !it.isCompleted } + val epResumeFraction = epEntry?.progressPercent + ?.takeIf { it > 0f } + ?.let { (it / 100f).coerceIn(0f, 1f) } + val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L + + activeSourceUrl = localFileUri + activeSourceAudioUrl = null + activeSourceHeaders = emptyMap() + activeSourceResponseHeaders = emptyMap() + activeStreamTitle = downloadItem.streamTitle.ifBlank { + episode.title.ifBlank { title } + } + activeStreamSubtitle = downloadItem.streamSubtitle + activeProviderName = downloadItem.providerName.ifBlank { "Downloaded" } + activeProviderAddonId = downloadItem.providerAddonId + currentStreamBingeGroup = null + activeSeasonNumber = episode.season + activeEpisodeNumber = episode.episode + activeEpisodeTitle = episode.title + activeEpisodeThumbnail = episode.thumbnail + activeVideoId = resolvedVideoId + activeInitialPositionMs = epResumePositionMs + activeInitialProgressFraction = epResumeFraction + controlsVisible = true + } + fun playNextEpisode() { val nextVideoId = nextEpisodeInfo?.videoId ?: return val nextVideo = allEpisodes.firstOrNull { video -> video.id == nextVideoId } ?: return if (nextEpisodeInfo?.hasAired != true) return + val downloadedNextEpisode = DownloadsRepository.findPlayableDownload( + parentMetaId = parentMetaId, + seasonNumber = nextVideo.season, + episodeNumber = nextVideo.episode, + videoId = nextVideo.id, + ) + if (downloadedNextEpisode != null) { + switchToDownloadedEpisode(downloadedNextEpisode, nextVideo) + return + } + nextEpisodeAutoPlayJob?.cancel() nextEpisodeAutoPlaySearching = true nextEpisodeAutoPlaySourceName = null @@ -1596,6 +1657,17 @@ fun PlayerScreen( ), onSeasonSelected = { /* season tab change handled internally */ }, onEpisodeSelected = { episode -> + val downloadedEpisode = DownloadsRepository.findPlayableDownload( + parentMetaId = parentMetaId, + seasonNumber = episode.season, + episodeNumber = episode.episode, + videoId = episode.id, + ) + if (downloadedEpisode != null) { + switchToDownloadedEpisode(downloadedEpisode, episode) + return@PlayerEpisodesPanel + } + val type = contentType ?: parentMetaType PlayerStreamsRepository.loadEpisodeStreams( type = type, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 33c6804b..522a0c40 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -7,6 +7,7 @@ import com.nuvio.app.core.auth.isAnonymous import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.collection.CollectionRepository +import com.nuvio.app.features.downloads.DownloadsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.library.LibraryRepository @@ -147,6 +148,7 @@ object ProfileRepository { TraktAuthRepository.onProfileChanged() SearchHistoryRepository.onProfileChanged() CollectionRepository.onProfileChanged() + DownloadsRepository.onProfileChanged() } suspend fun pushProfiles(profiles: List) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index 6bebcbef..4399bbbe 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.AccountCircle +import androidx.compose.material.icons.rounded.CloudDownload import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.Link import androidx.compose.material.icons.rounded.Notifications @@ -26,6 +27,7 @@ internal fun LazyListScope.settingsRootContent( onContentDiscoveryClick: () -> Unit, onIntegrationsClick: () -> Unit, onTraktClick: () -> Unit, + onDownloadsClick: () -> Unit, onAccountClick: () -> Unit, onSwitchProfileClick: (() -> Unit)? = null, showAccountSection: Boolean = true, @@ -98,6 +100,14 @@ internal fun LazyListScope.settingsRootContent( onClick = onPlaybackClick, ) SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = "Downloads", + description = "Manage your downloaded movies and episodes.", + icon = Icons.Rounded.CloudDownload, + isTablet = isTablet, + onClick = onDownloadsClick, + ) + SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( title = "Integrations", description = "Connect TMDB and MDBList services.", diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 8762ed87..f5dfad2d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -66,6 +66,7 @@ fun SettingsScreen( onContinueWatchingClick: () -> Unit = {}, onAddonsClick: () -> Unit = {}, onPluginsClick: () -> Unit = {}, + onDownloadsClick: () -> Unit = {}, onAccountClick: () -> Unit = {}, onCollectionsClick: () -> Unit = {}, ) { @@ -179,6 +180,7 @@ fun SettingsScreen( metaScreenSettingsUiState = metaScreenSettingsUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, onSwitchProfile = onSwitchProfile, + onDownloadsClick = onDownloadsClick, onCollectionsClick = onCollectionsClick, ) } else { @@ -218,6 +220,7 @@ fun SettingsScreen( onContinueWatchingClick = onContinueWatchingClick, onAddonsClick = onAddonsClick, onPluginsClick = onPluginsClick, + onDownloadsClick = onDownloadsClick, onAccountClick = onAccountClick, onCollectionsClick = onCollectionsClick, ) @@ -262,6 +265,7 @@ private fun MobileSettingsScreen( onContinueWatchingClick: () -> Unit = {}, onAddonsClick: () -> Unit = {}, onPluginsClick: () -> Unit = {}, + onDownloadsClick: () -> Unit = {}, onAccountClick: () -> Unit = {}, onCollectionsClick: () -> Unit = {}, ) { @@ -283,6 +287,7 @@ private fun MobileSettingsScreen( onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) }, onIntegrationsClick = { onPageChange(SettingsPage.Integrations) }, onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) }, + onDownloadsClick = onDownloadsClick, onAccountClick = onAccountClick, onSwitchProfileClick = onSwitchProfile, ) @@ -400,6 +405,7 @@ private fun TabletSettingsScreen( metaScreenSettingsUiState: MetaScreenSettingsUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, onSwitchProfile: (() -> Unit)? = null, + onDownloadsClick: () -> Unit = {}, onCollectionsClick: () -> Unit = {}, ) { var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) } @@ -487,6 +493,7 @@ private fun TabletSettingsScreen( onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) }, onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) }, onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) }, + onDownloadsClick = onDownloadsClick, onAccountClick = { openInlinePage(SettingsPage.Account) }, onSwitchProfileClick = onSwitchProfile, showAccountSection = activeCategory == SettingsCategory.Account, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 1c26b927..ff6311e2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -37,6 +38,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.SearchOff import androidx.compose.material3.CircularProgressIndicator @@ -48,6 +51,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -65,12 +69,22 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import com.nuvio.app.core.ui.NuvioBackButton +import com.nuvio.app.core.ui.NuvioBottomSheetActionRow +import com.nuvio.app.core.ui.NuvioBottomSheetDivider +import com.nuvio.app.core.ui.NuvioModalBottomSheet +import com.nuvio.app.core.ui.NuvioToastController +import com.nuvio.app.core.ui.dismissNuvioBottomSheet +import com.nuvio.app.features.downloads.DownloadsRepository import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState import coil3.compose.AsyncImage import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding import com.nuvio.app.features.watchprogress.WatchProgressRepository -import com.nuvio.app.features.watchprogress.buildPlaybackVideoId +import kotlinx.coroutines.launch import kotlin.math.round import kotlin.math.roundToInt @@ -105,7 +119,12 @@ fun StreamsScreen( WatchProgressRepository.ensureLoaded() WatchProgressRepository.uiState }.collectAsStateWithLifecycle() + remember { + DownloadsRepository.ensureLoaded() + } val isEpisode = seasonNumber != null && episodeNumber != null + val clipboardManager = LocalClipboardManager.current + var streamActionsTarget by remember(videoId) { mutableStateOf(null) } var preferredFilterApplied by remember(videoId) { mutableStateOf(false) } val storedProgress = if (startFromBeginning) { null @@ -180,6 +199,7 @@ fun StreamsScreen( resumePositionMs = effectiveResumePositionMs, resumeProgressFraction = effectiveResumeProgressFraction, onStreamSelected = onStreamSelected, + onStreamLongPress = { stream -> streamActionsTarget = stream }, ) } else { MobileStreamsLayout( @@ -194,6 +214,7 @@ fun StreamsScreen( resumePositionMs = effectiveResumePositionMs, resumeProgressFraction = effectiveResumeProgressFraction, onStreamSelected = onStreamSelected, + onStreamLongPress = { stream -> streamActionsTarget = stream }, ) } @@ -279,6 +300,38 @@ fun StreamsScreen( } } } + + StreamActionsSheet( + stream = streamActionsTarget, + onDismiss = { streamActionsTarget = null }, + onCopyLink = { stream -> + val directUrl = stream.directPlaybackUrl + if (!directUrl.isNullOrBlank()) { + clipboardManager.setText(AnnotatedString(directUrl)) + NuvioToastController.show("Stream link copied") + } else { + NuvioToastController.show("No direct stream link available") + } + }, + onDownload = { stream -> + val result = DownloadsRepository.enqueueFromStream( + contentType = type, + videoId = videoId, + parentMetaId = parentMetaId, + parentMetaType = parentMetaType, + title = title, + logo = logo, + poster = poster, + background = background, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + episodeThumbnail = episodeThumbnail, + stream = stream, + ) + NuvioToastController.show(result.toastMessage) + }, + ) } } @@ -295,6 +348,7 @@ private fun MobileStreamsLayout( resumePositionMs: Long?, resumeProgressFraction: Float?, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, + onStreamLongPress: (StreamItem) -> Unit, modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize()) { @@ -372,6 +426,7 @@ private fun MobileStreamsLayout( StreamList( uiState = uiState, onStreamSelected = onStreamSelected, + onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, resumeProgressFraction = resumeProgressFraction, modifier = Modifier.weight(1f), @@ -658,6 +713,7 @@ private fun FilterChip( internal fun StreamList( uiState: StreamsUiState, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, + onStreamLongPress: (StreamItem) -> Unit, resumePositionMs: Long?, resumeProgressFraction: Float?, modifier: Modifier = Modifier, @@ -694,6 +750,7 @@ internal fun StreamList( group = group, showHeader = uiState.selectedFilter == null, onStreamSelected = onStreamSelected, + onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, resumeProgressFraction = resumeProgressFraction, ) @@ -715,6 +772,7 @@ private fun LazyListScope.streamSection( group: AddonStreamGroup, showHeader: Boolean, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, + onStreamLongPress: (StreamItem) -> Unit, resumePositionMs: Long?, resumeProgressFraction: Float?, ) { @@ -756,6 +814,11 @@ private fun LazyListScope.streamSection( onStreamSelected(stream, resumePositionMs, resumeProgressFraction) } }, + onLongClick = { + if (stream.directPlaybackUrl != null) { + onStreamLongPress(stream) + } + }, ) Spacer(modifier = Modifier.height(10.dp)) } @@ -830,6 +893,7 @@ private fun StreamSourceHeader( private fun StreamCard( stream: StreamItem, onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val isEnabled = stream.directPlaybackUrl != null @@ -846,7 +910,11 @@ private fun StreamCard( ) .clip(cardShape) .background(Color.White.copy(alpha = 0.05f)) - .clickable(enabled = isEnabled, onClick = onClick) + .combinedClickable( + enabled = isEnabled, + onClick = onClick, + onLongClick = onLongClick, + ) .padding(14.dp), verticalAlignment = Alignment.Top, ) { @@ -883,6 +951,85 @@ private fun StreamCard( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun StreamActionsSheet( + stream: StreamItem?, + onDismiss: () -> Unit, + onCopyLink: (StreamItem) -> Unit, + onDownload: (StreamItem) -> Unit, +) { + if (stream == null) return + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val coroutineScope = rememberCoroutineScope() + + NuvioModalBottomSheet( + onDismissRequest = { + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp + nuvioPlatformExtraBottomPadding), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stream.streamLabel, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + stream.streamSubtitle + ?.takeIf { it.isNotBlank() } + ?.let { subtitle -> + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + + NuvioBottomSheetDivider() + NuvioBottomSheetActionRow( + icon = Icons.Rounded.ContentCopy, + title = "Copy stream link", + onClick = { + onCopyLink(stream) + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + ) + NuvioBottomSheetDivider() + NuvioBottomSheetActionRow( + icon = Icons.Rounded.Download, + title = "Download file", + onClick = { + onDownload(stream) + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + ) + } + } +} + @Composable private fun StreamFileSizeBadge(stream: StreamItem) { val bytes = stream.behaviorHints.videoSize ?: return diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt index 203e97ca..72ef11dd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt @@ -61,6 +61,7 @@ internal fun TabletStreamsLayout( resumePositionMs: Long?, resumeProgressFraction: Float?, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, + onStreamLongPress: (StreamItem) -> Unit, modifier: Modifier = Modifier, ) { val hazeState = rememberHazeState() @@ -202,6 +203,7 @@ internal fun TabletStreamsLayout( StreamList( uiState = uiState, onStreamSelected = onStreamSelected, + onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, resumeProgressFraction = resumeProgressFraction, modifier = Modifier.weight(1f), diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.ios.kt new file mode 100644 index 00000000..23536a33 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsClock.ios.kt @@ -0,0 +1,9 @@ +package com.nuvio.app.features.downloads + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.posix.time + +internal actual object DownloadsClock { + @OptIn(ExperimentalForeignApi::class) + actual fun nowEpochMs(): Long = time(null) * 1000L +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.ios.kt new file mode 100644 index 00000000..09a43055 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.ios.kt @@ -0,0 +1,87 @@ +package com.nuvio.app.features.downloads + +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSUserDefaults + +internal actual object DownloadsLiveStatusPlatform { + private const val notificationName = "NuvioDownloadsLiveStatusUpdated" + private const val userDefaultsPayloadKey = "nuvio.downloads.live_status.payload" + + private val json = Json { + encodeDefaults = true + } + + private var lastPayload: String? = null + + actual fun onItemsChanged(items: List) { + val primary = items + .filter { item -> + item.status == DownloadStatus.Downloading || + item.status == DownloadStatus.Paused || + item.status == DownloadStatus.Failed + } + .sortedWith( + compareBy { statusPriority(it.status) } + .thenByDescending { it.updatedAtEpochMs }, + ) + .firstOrNull() + + val payload = primary?.let { item -> + json.encodeToString( + DownloadsLiveStatusPayload( + id = item.id, + title = item.title, + subtitle = item.displaySubtitle, + posterUrl = item.episodeThumbnail + ?.takeIf { it.isNotBlank() } + ?: item.poster?.takeIf { it.isNotBlank() } + ?: item.background?.takeIf { it.isNotBlank() }, + status = item.status.name, + downloadedBytes = item.downloadedBytes, + totalBytes = item.totalBytes, + progressPercent = if (item.totalBytes != null && item.totalBytes > 0L) { + ((item.downloadedBytes.toDouble() / item.totalBytes.toDouble()) * 100.0) + .toInt() + .coerceIn(0, 100) + } else { + -1 + }, + ), + ) + } + + if (payload == lastPayload) return + lastPayload = payload + + val defaults = NSUserDefaults.standardUserDefaults + if (payload == null) { + defaults.removeObjectForKey(userDefaultsPayloadKey) + } else { + defaults.setObject(payload, forKey = userDefaultsPayloadKey) + } + + NSNotificationCenter.defaultCenter.postNotificationName(notificationName, null) + } + + private fun statusPriority(status: DownloadStatus): Int = when (status) { + DownloadStatus.Downloading -> 0 + DownloadStatus.Paused -> 1 + DownloadStatus.Failed -> 2 + DownloadStatus.Completed -> 3 + } +} + +@Serializable +private data class DownloadsLiveStatusPayload( + val id: String, + val title: String, + val subtitle: String, + val posterUrl: String? = null, + val status: String, + val downloadedBytes: Long, + val totalBytes: Long? = null, + val progressPercent: Int, +) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt new file mode 100644 index 00000000..746a742e --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt @@ -0,0 +1,194 @@ +package com.nuvio.app.features.downloads + +import io.ktor.client.HttpClient +import io.ktor.client.engine.darwin.Darwin +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.isSuccess +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.readAvailable +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.convert +import kotlinx.cinterop.usePinned +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import platform.Foundation.NSFileManager +import platform.Foundation.NSHomeDirectory +import platform.Foundation.NSURL +import platform.posix.fclose +import platform.posix.fopen +import platform.posix.fwrite + +private val downloadHttpClient = HttpClient(Darwin) { + install(HttpTimeout) { + requestTimeoutMillis = 60_000 + connectTimeoutMillis = 60_000 + socketTimeoutMillis = 60_000 + } + expectSuccess = false +} + +@OptIn(ExperimentalForeignApi::class) +internal actual object DownloadsPlatformDownloader { + actual fun start( + request: DownloadPlatformRequest, + onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, + onSuccess: (localFileUri: String, totalBytes: Long?) -> Unit, + onFailure: (message: String) -> Unit, + ): DownloadsTaskHandle { + val job = SupervisorJob() + val scope = CoroutineScope(job + Dispatchers.Default) + + scope.launch { + val downloadsDirectory = downloadsDirectoryPath() + val destinationPath = "$downloadsDirectory/${request.destinationFileName}" + val tempPath = "$downloadsDirectory/${request.destinationFileName}.part" + + removePathIfExists(tempPath) + + try { + val response = downloadHttpClient.get(request.sourceUrl) { + request.sourceHeaders.forEach { (key, value) -> + header(key, value) + } + } + + if (!response.status.isSuccess()) { + error("Request failed with HTTP ${response.status.value}") + } + + val totalBytes = response.headers["Content-Length"]?.toLongOrNull()?.takeIf { it > 0L } + val channel = response.bodyAsChannel() + val wrote = writeChannelToFile( + channel = channel, + path = tempPath, + totalBytes = totalBytes, + onProgress = onProgress, + ) + if (!wrote) { + error("Failed to write download file") + } + + removePathIfExists(destinationPath) + val moved = NSFileManager.defaultManager.moveItemAtPath( + srcPath = tempPath, + toPath = destinationPath, + error = null, + ) + if (!moved) { + error("Failed to finalize download file") + } + + val localFileUri = NSURL.fileURLWithPath(destinationPath).absoluteString ?: "file://$destinationPath" + val finalSize = fileSizeOrNull(destinationPath) + onSuccess(localFileUri, totalBytes ?: finalSize) + } catch (_: CancellationException) { + removePathIfExists(tempPath) + } catch (error: Throwable) { + removePathIfExists(tempPath) + onFailure(error.message ?: "Download failed") + } + } + + return IosDownloadsTaskHandle(job) + } + + actual fun removeFile(localFileUri: String?): Boolean { + if (localFileUri.isNullOrBlank()) return false + val path = localFileUri.toLocalPath() ?: return false + return removePathIfExists(path) + } +} + +private class IosDownloadsTaskHandle( + private val job: Job, +) : DownloadsTaskHandle { + override fun cancel() { + job.cancel() + } +} + +@OptIn(ExperimentalForeignApi::class) +private fun downloadsDirectoryPath(): String { + val root = NSHomeDirectory().trimEnd('/') + val path = "$root/Documents/nuvio_downloads" + NSFileManager.defaultManager.createDirectoryAtPath( + path = path, + withIntermediateDirectories = true, + attributes = null, + error = null, + ) + return path +} + +@OptIn(ExperimentalForeignApi::class) +private fun removePathIfExists(path: String): Boolean { + if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return true + return NSFileManager.defaultManager.removeItemAtPath(path, null) +} + +@OptIn(ExperimentalForeignApi::class) +private suspend fun writeChannelToFile( + channel: ByteReadChannel, + path: String, + totalBytes: Long?, + onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, +): Boolean { + val file = fopen(path, "wb") ?: return false + val buffer = ByteArray(16 * 1024) + var downloadedBytes = 0L + + return try { + while (true) { + kotlinx.coroutines.currentCoroutineContext().ensureActive() + val read = channel.readAvailable(buffer, 0, buffer.size) + if (read < 0) break + if (read == 0) continue + + val wroteChunk = buffer.usePinned { pinned -> + val written = fwrite( + pinned.addressOf(0), + 1.convert(), + read.convert(), + file, + ) + written.toInt() == read + } + if (!wroteChunk) { + return false + } + + downloadedBytes += read.toLong() + onProgress(downloadedBytes, totalBytes) + } + true + } finally { + fclose(file) + } +} + +@OptIn(ExperimentalForeignApi::class) +private fun fileSizeOrNull(path: String): Long? { + val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, error = null) + val value = attrs?.get("NSFileSize") + return when (value) { + is Long -> value + is Number -> value.toLong() + else -> null + } +} + +private fun String.toLocalPath(): String? { + if (startsWith("file://")) { + return removePrefix("file://") + } + return takeIf { it.isNotBlank() } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.ios.kt new file mode 100644 index 00000000..1fe7cbf6 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsStorage.ios.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.downloads + +import com.nuvio.app.core.storage.ProfileScopedKey +import platform.Foundation.NSUserDefaults + +internal actual object DownloadsStorage { + private const val payloadKey = "downloads_payload" + + actual fun loadPayload(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey)) + } +} diff --git a/iosApp/DownloadsWidgetExtension/DownloadsLiveActivityWidget.swift b/iosApp/DownloadsWidgetExtension/DownloadsLiveActivityWidget.swift new file mode 100644 index 00000000..ffbb8fc5 --- /dev/null +++ b/iosApp/DownloadsWidgetExtension/DownloadsLiveActivityWidget.swift @@ -0,0 +1,220 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +struct DownloadsLiveActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + let status: String + let progressPercent: Int + let transferredText: String + } + + let downloadId: String + let title: String + let subtitle: String + let posterUrl: String? +} + +@available(iOSApplicationExtension 16.1, *) +struct DownloadsLiveActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: DownloadsLiveActivityAttributes.self) { context in + DownloadActivityLockScreenView(context: context) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + PosterThumbnailView(urlString: context.attributes.posterUrl) + .frame(width: 44, height: 64) + } + DynamicIslandExpandedRegion(.trailing) { + VStack(alignment: .trailing, spacing: 3) { + Text(progressLabel(context.state.progressPercent)) + .font(.headline.monospacedDigit()) + .foregroundStyle(.primary) + Text(statusLabel(context.state.status)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + DynamicIslandExpandedRegion(.bottom) { + VStack(alignment: .leading, spacing: 8) { + Text(context.attributes.title) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + Text(context.attributes.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + ProgressView(value: normalizedProgress(context.state.progressPercent)) + .progressViewStyle(.linear) + HStack { + Text(context.state.transferredText) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + Spacer(minLength: 6) + Text(statusLabel(context.state.status)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } compactLeading: { + PosterGlyphView() + } compactTrailing: { + Text(progressLabel(context.state.progressPercent)) + .font(.caption2.monospacedDigit()) + } minimal: { + PosterGlyphView() + } + } + } + + private func progressLabel(_ progressPercent: Int) -> String { + if progressPercent < 0 { return "--%" } + return "\(max(0, min(100, progressPercent)))%" + } + + private func normalizedProgress(_ progressPercent: Int) -> Double { + guard progressPercent >= 0 else { return 0 } + return min(max(Double(progressPercent) / 100.0, 0), 1) + } + + private func statusLabel(_ status: String) -> String { + switch status.lowercased() { + case "downloading": return "Downloading" + case "paused": return "Paused" + case "failed": return "Failed" + default: return "Active" + } + } +} + +@available(iOSApplicationExtension 16.1, *) +private struct DownloadActivityLockScreenView: View { + let context: ActivityViewContext + + var body: some View { + HStack(alignment: .top, spacing: 12) { + PosterThumbnailView(urlString: context.attributes.posterUrl) + .frame(width: 62, height: 92) + + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(context.attributes.title) + .font(.headline) + .lineLimit(1) + Spacer(minLength: 8) + Text(progressLabel(context.state.progressPercent)) + .font(.headline.monospacedDigit()) + } + Text(context.attributes.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + ProgressView(value: normalizedProgress(context.state.progressPercent)) + .progressViewStyle(.linear) + HStack { + Text(statusLabel(context.state.status)) + .font(.caption2) + .foregroundStyle(.secondary) + Spacer() + Text(context.state.transferredText) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + } + .padding(.horizontal, 6) + .padding(.vertical, 6) + .activityBackgroundTint(Color(red: 0.10, green: 0.11, blue: 0.16).opacity(0.88)) + .activitySystemActionForegroundColor(.white) + } + + private func progressLabel(_ progressPercent: Int) -> String { + if progressPercent < 0 { return "--%" } + return "\(max(0, min(100, progressPercent)))%" + } + + private func normalizedProgress(_ progressPercent: Int) -> Double { + guard progressPercent >= 0 else { return 0 } + return min(max(Double(progressPercent) / 100.0, 0), 1) + } + + private func statusLabel(_ status: String) -> String { + switch status.lowercased() { + case "downloading": return "Downloading" + case "paused": return "Paused" + case "failed": return "Failed" + default: return "Active" + } + } +} + +private struct PosterGlyphView: View { + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.white.opacity(0.14)) + Image(systemName: "film") + .font(.caption) + .foregroundStyle(.white.opacity(0.9)) + } + .frame(width: 22, height: 22) + } +} + +private struct PosterThumbnailView: View { + let urlString: String? + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill( + LinearGradient( + colors: [Color(red: 0.16, green: 0.17, blue: 0.22), Color(red: 0.09, green: 0.10, blue: 0.14)], + startPoint: .topLeading, + endPoint: .bottomTrailing, + ), + ) + + if let url = imageUrl { + AsyncImage(url: url) { phase in + switch phase { + case .empty: + ProgressView() + .tint(.white.opacity(0.85)) + case .success(let image): + image + .resizable() + .scaledToFill() + case .failure: + fallbackIcon + @unknown default: + fallbackIcon + } + } + } else { + fallbackIcon + } + } + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.white.opacity(0.12), lineWidth: 1), + ) + .shadow(color: .black.opacity(0.24), radius: 6, x: 0, y: 3) + } + + private var imageUrl: URL? { + guard let urlString, let url = URL(string: urlString), !urlString.isEmpty else { + return nil + } + return url + } + + private var fallbackIcon: some View { + Image(systemName: "film") + .font(.title3) + .foregroundStyle(.white.opacity(0.82)) + } +} diff --git a/iosApp/DownloadsWidgetExtension/DownloadsWidgetBundle.swift b/iosApp/DownloadsWidgetExtension/DownloadsWidgetBundle.swift new file mode 100644 index 00000000..d47be646 --- /dev/null +++ b/iosApp/DownloadsWidgetExtension/DownloadsWidgetBundle.swift @@ -0,0 +1,9 @@ +import WidgetKit +import SwiftUI + +@main +struct DownloadsWidgetBundle: WidgetBundle { + var body: some Widget { + DownloadsLiveActivityWidget() + } +} diff --git a/iosApp/DownloadsWidgetExtension/Info.plist b/iosApp/DownloadsWidgetExtension/Info.plist new file mode 100644 index 00000000..3070d197 --- /dev/null +++ b/iosApp/DownloadsWidgetExtension/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Downloads + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + MinimumOSVersion + $(IPHONEOS_DEPLOYMENT_TARGET) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 12411d63..ae30fd0b 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -7,14 +7,47 @@ objects = { /* Begin PBXBuildFile section */ + 0A1B2C3D4E5F60718293A4BD /* DownloadsWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 0A1B2C3D4E5F60718293A4B5 /* DownloadsWidgetExtension.appex */; }; 7397CF462F80FE3A00AC0F84 /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = A1B2C3D4E5F6A7B8C9D0E1F2 /* MPVKit */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 0A1B2C3D4E5F60718293A4C1 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E819B502921EC7C68AC4965A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 0A1B2C3D4E5F60718293A4B8; + remoteInfo = DownloadsWidgetExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 0A1B2C3D4E5F60718293A4BC /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 0A1B2C3D4E5F60718293A4BD /* DownloadsWidgetExtension.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ + 0A1B2C3D4E5F60718293A4B5 /* DownloadsWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DownloadsWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 07921B4E8F8546BEE682C2FE /* Nuvio.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Nuvio.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 0A1B2C3D4E5F60718293A4B7 /* Exceptions for "DownloadsWidgetExtension" folder in "DownloadsWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 0A1B2C3D4E5F60718293A4B8 /* DownloadsWidgetExtension */; + }; 77B823F8A18A09B45EA3C7D4 /* Exceptions for "iosApp" folder in "iosApp" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -25,6 +58,14 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 0A1B2C3D4E5F60718293A4B6 /* DownloadsWidgetExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 0A1B2C3D4E5F60718293A4B7 /* Exceptions for "DownloadsWidgetExtension" folder in "DownloadsWidgetExtension" target */, + ); + path = DownloadsWidgetExtension; + sourceTree = ""; + }; 69649F6DF5D3AF53A24CA9C3 /* Configuration */ = { isa = PBXFileSystemSynchronizedRootGroup; path = Configuration; @@ -41,6 +82,13 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 0A1B2C3D4E5F60718293A4BA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; B8D8368DBB6E1F38F403E5AA /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -57,6 +105,7 @@ children = ( 69649F6DF5D3AF53A24CA9C3 /* Configuration */, A2F97F59D21EB4D4B662C8D1 /* iosApp */, + 0A1B2C3D4E5F60718293A4B6 /* DownloadsWidgetExtension */, AD93033A33973C81BB77B73A /* Products */, ); sourceTree = ""; @@ -65,6 +114,7 @@ isa = PBXGroup; children = ( 07921B4E8F8546BEE682C2FE /* Nuvio.app */, + 0A1B2C3D4E5F60718293A4B5 /* DownloadsWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -72,6 +122,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 0A1B2C3D4E5F60718293A4B8 /* DownloadsWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0A1B2C3D4E5F60718293A4BE /* Build configuration list for PBXNativeTarget "DownloadsWidgetExtension" */; + buildPhases = ( + 0A1B2C3D4E5F60718293A4B9 /* Sources */, + 0A1B2C3D4E5F60718293A4BA /* Frameworks */, + 0A1B2C3D4E5F60718293A4BB /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 0A1B2C3D4E5F60718293A4B6 /* DownloadsWidgetExtension */, + ); + name = DownloadsWidgetExtension; + packageProductDependencies = ( + ); + productName = DownloadsWidgetExtension; + productReference = 0A1B2C3D4E5F60718293A4B5 /* DownloadsWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; E1B229BC363ABB711AF255E3 /* iosApp */ = { isa = PBXNativeTarget; buildConfigurationList = E4075507B4A6F771FEF44E6F /* Build configuration list for PBXNativeTarget "iosApp" */; @@ -79,11 +151,13 @@ 8C96BAEBBE1F4269A0BADC2B /* Compile Kotlin Framework */, 79E42700D221AF24510E3E09 /* Sources */, B8D8368DBB6E1F38F403E5AA /* Frameworks */, + 0A1B2C3D4E5F60718293A4BC /* Embed App Extensions */, 2DAFDB313E32F227766A2072 /* Resources */, ); buildRules = ( ); dependencies = ( + 0A1B2C3D4E5F60718293A4C2 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( A2F97F59D21EB4D4B662C8D1 /* iosApp */, @@ -106,6 +180,9 @@ LastSwiftUpdateCheck = 1620; LastUpgradeCheck = 1620; TargetAttributes = { + 0A1B2C3D4E5F60718293A4B8 = { + CreatedOnToolsVersion = 16.2; + }; E1B229BC363ABB711AF255E3 = { CreatedOnToolsVersion = 16.2; }; @@ -128,12 +205,20 @@ projectDirPath = ""; projectRoot = ""; targets = ( + 0A1B2C3D4E5F60718293A4B8 /* DownloadsWidgetExtension */, E1B229BC363ABB711AF255E3 /* iosApp */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 0A1B2C3D4E5F60718293A4BB /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 2DAFDB313E32F227766A2072 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -166,6 +251,13 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 0A1B2C3D4E5F60718293A4B9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 79E42700D221AF24510E3E09 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -175,7 +267,63 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 0A1B2C3D4E5F60718293A4C2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 0A1B2C3D4E5F60718293A4B8 /* DownloadsWidgetExtension */; + targetProxy = 0A1B2C3D4E5F60718293A4C1 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 0A1B2C3D4E5F60718293A4BF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 8QBDZ766S3; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = DownloadsWidgetExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app.Nuvio.DownloadsWidgetExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 0A1B2C3D4E5F60718293A4C0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 8QBDZ766S3; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = DownloadsWidgetExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app.Nuvio.DownloadsWidgetExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; 08C6158BC74ED5D18954BFD9 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = 69649F6DF5D3AF53A24CA9C3 /* Configuration */; @@ -358,6 +506,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 0A1B2C3D4E5F60718293A4BE /* Build configuration list for PBXNativeTarget "DownloadsWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0A1B2C3D4E5F60718293A4BF /* Debug */, + 0A1B2C3D4E5F60718293A4C0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; BC8D3A48007CFD75F9C7AB47 /* Build configuration list for PBXProject "iosApp" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/iosApp/iosApp/DownloadsLiveActivityManager.swift b/iosApp/iosApp/DownloadsLiveActivityManager.swift new file mode 100644 index 00000000..02356ea9 --- /dev/null +++ b/iosApp/iosApp/DownloadsLiveActivityManager.swift @@ -0,0 +1,132 @@ +import Foundation +#if canImport(ActivityKit) && os(iOS) && !targetEnvironment(macCatalyst) +import ActivityKit +#endif + +private let downloadsLiveStatusUpdatedNotification = Notification.Name("NuvioDownloadsLiveStatusUpdated") +private let downloadsLiveStatusPayloadKey = "nuvio.downloads.live_status.payload" + +final class DownloadsLiveActivityManager { + static let shared = DownloadsLiveActivityManager() + + private var observer: NSObjectProtocol? + + private init() {} + + func start() { + guard observer == nil else { return } + + observer = NotificationCenter.default.addObserver( + forName: downloadsLiveStatusUpdatedNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.syncFromPayloadStore() + } + + syncFromPayloadStore() + } + + private func syncFromPayloadStore() { +#if canImport(ActivityKit) && os(iOS) && !targetEnvironment(macCatalyst) + guard #available(iOS 16.1, *) else { return } + + let payload = loadPayload() + Task { + await apply(payload) + } +#endif + } + + private func loadPayload() -> DownloadsLiveStatusPayload? { + guard let encoded = UserDefaults.standard.string(forKey: downloadsLiveStatusPayloadKey) else { + return nil + } + let data = Data(encoded.utf8) + return try? JSONDecoder().decode(DownloadsLiveStatusPayload.self, from: data) + } + +#if canImport(ActivityKit) && os(iOS) && !targetEnvironment(macCatalyst) + @available(iOS 16.1, *) + private func apply(_ payload: DownloadsLiveStatusPayload?) async { + let existing = Activity.activities.first + + guard let payload else { + if let existing { + await existing.end(dismissalPolicy: .immediate) + } + return + } + + let state = DownloadsLiveActivityAttributes.ContentState( + status: payload.status, + progressPercent: payload.progressPercent, + transferredText: transferredText(payload) + ) + + if let existing, existing.attributes.downloadId == payload.id { + await existing.update(using: state) + return + } + + if let existing { + await existing.end(dismissalPolicy: .immediate) + } + + let attributes = DownloadsLiveActivityAttributes( + downloadId: payload.id, + title: payload.title, + subtitle: payload.subtitle, + posterUrl: payload.posterUrl + ) + + _ = try? Activity.request( + attributes: attributes, + contentState: state, + pushType: nil + ) + } +#endif + + private func transferredText(_ payload: DownloadsLiveStatusPayload) -> String { + let downloaded = formatBytes(payload.downloadedBytes) + if let total = payload.totalBytes { + return "\(downloaded) / \(formatBytes(total))" + } + return downloaded + } + + private func formatBytes(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useKB, .useMB, .useGB] + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } +} + +#if canImport(ActivityKit) && os(iOS) && !targetEnvironment(macCatalyst) +@available(iOS 16.1, *) +struct DownloadsLiveActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + let status: String + let progressPercent: Int + let transferredText: String + } + + let downloadId: String + let title: String + let subtitle: String + let posterUrl: String? +} +#endif + +private struct DownloadsLiveStatusPayload: Decodable { + let id: String + let title: String + let subtitle: String + let posterUrl: String? + let status: String + let downloadedBytes: Int64 + let totalBytes: Int64? + let progressPercent: Int +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 8f66dba4..4f941103 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -17,5 +17,7 @@ + NSSupportsLiveActivities + diff --git a/iosApp/iosApp/OrientationLockCoordinator.swift b/iosApp/iosApp/OrientationLockCoordinator.swift index cf9c6d8c..5d514e02 100644 --- a/iosApp/iosApp/OrientationLockCoordinator.swift +++ b/iosApp/iosApp/OrientationLockCoordinator.swift @@ -11,6 +11,7 @@ final class OrientationLockAppDelegate: NSObject, UIApplicationDelegate, UNUserN didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { OrientationLockCoordinator.shared.start() + DownloadsLiveActivityManager.shared.start() UNUserNotificationCenter.current().delegate = self return true }