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 }