From c0bce25f29504105bde749b7ba64dda3cce13e34 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sat, 11 Apr 2026 22:34:23 +0530
Subject: [PATCH] init: live notifications for downloads
---
.../src/androidMain/AndroidManifest.xml | 4 +
.../kotlin/com/nuvio/app/MainActivity.kt | 2 +
.../DownloadsLiveStatusPlatform.android.kt | 258 ++++++++++++++++++
.../DownloadsNotificationActionReceiver.kt | 29 ++
.../commonMain/kotlin/com/nuvio/app/App.kt | 8 +
.../nuvio/app/core/deeplink/AppUrlBridge.kt | 6 +
.../downloads/DownloadsLiveStatusPlatform.kt | 5 +
.../features/downloads/DownloadsRepository.kt | 10 +
.../DownloadsLiveStatusPlatform.ios.kt | 87 ++++++
.../DownloadsLiveActivityWidget.swift | 220 +++++++++++++++
.../DownloadsWidgetBundle.swift | 9 +
iosApp/DownloadsWidgetExtension/Info.plist | 31 +++
iosApp/iosApp.xcodeproj/project.pbxproj | 157 +++++++++++
.../iosApp/DownloadsLiveActivityManager.swift | 132 +++++++++
iosApp/iosApp/Info.plist | 2 +
.../iosApp/OrientationLockCoordinator.swift | 1 +
16 files changed, 961 insertions(+)
create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.android.kt
create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsNotificationActionReceiver.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.kt
create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.ios.kt
create mode 100644 iosApp/DownloadsWidgetExtension/DownloadsLiveActivityWidget.swift
create mode 100644 iosApp/DownloadsWidgetExtension/DownloadsWidgetBundle.swift
create mode 100644 iosApp/DownloadsWidgetExtension/Info.plist
create mode 100644 iosApp/iosApp/DownloadsLiveActivityManager.swift
diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index 06eec0d5e..a4b486729 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 aac40eb91..822df483f 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
@@ -13,6 +13,7 @@ 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
@@ -73,6 +74,7 @@ class MainActivity : ComponentActivity() {
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/DownloadsLiveStatusPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.android.kt
new file mode 100644
index 000000000..1aab51c73
--- /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 000000000..be7b3c66b
--- /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/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index cd3e7f696..cf9a17c2e 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -491,6 +491,14 @@ private fun MainAppContent(
AppDeepLinkRepository.markConsumed(deepLink)
}
+ AppDeepLink.Downloads -> {
+ selectedTab = AppScreenTab.Settings
+ navController.navigate(DownloadsSettingsRoute) {
+ launchSingleTop = true
+ }
+ AppDeepLinkRepository.markConsumed(deepLink)
+ }
+
null -> Unit
}
}
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 6edb9e2b1..e36e3934b 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/DownloadsLiveStatusPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.kt
new file mode 100644
index 000000000..e3214228b
--- /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/DownloadsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt
index 746ffbd84..25b29dd7d 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt
@@ -32,6 +32,7 @@ object DownloadsRepository {
activeHandles.clear()
hasLoaded = false
_uiState.value = DownloadsUiState()
+ notifyLiveStatusPlatform()
}
fun findPlayableDownloadByVideoId(videoId: String?): DownloadItem? {
@@ -225,6 +226,7 @@ object DownloadsRepository {
val payload = DownloadsStorage.loadPayload().orEmpty().trim()
if (payload.isEmpty()) {
_uiState.value = DownloadsUiState()
+ notifyLiveStatusPlatform()
return
}
@@ -242,6 +244,7 @@ object DownloadsRepository {
.sortedByDescending { it.updatedAtEpochMs }
_uiState.value = DownloadsUiState(normalized)
+ notifyLiveStatusPlatform()
}
private fun startDownload(item: DownloadItem) {
@@ -331,6 +334,13 @@ object DownloadsRepository {
_uiState.value = DownloadsUiState(
items = items.sortedByDescending { it.updatedAtEpochMs },
)
+ notifyLiveStatusPlatform()
+ }
+
+ private fun notifyLiveStatusPlatform() {
+ runCatching {
+ DownloadsLiveStatusPlatform.onItemsChanged(_uiState.value.items)
+ }
}
private fun persist() {
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 000000000..09a430553
--- /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/iosApp/DownloadsWidgetExtension/DownloadsLiveActivityWidget.swift b/iosApp/DownloadsWidgetExtension/DownloadsLiveActivityWidget.swift
new file mode 100644
index 000000000..ffbb8fc50
--- /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 000000000..d47be646e
--- /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 000000000..3070d1974
--- /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 12411d63b..ae30fd0bc 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 000000000..02356ea9a
--- /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 8f66dba42..4f9411030 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 cf9c6d8cf..5d514e022 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
}