mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-27 19:33:02 +00:00
220 lines
7.9 KiB
Swift
220 lines
7.9 KiB
Swift
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<DownloadsLiveActivityAttributes>
|
|
|
|
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))
|
|
}
|
|
}
|