mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 09:35:42 +00:00
247 lines
8.5 KiB
Swift
247 lines
8.5 KiB
Swift
import SwiftUI
|
|
import WidgetKit
|
|
|
|
#if canImport(ActivityKit)
|
|
|
|
struct ConditionalForegroundViewModifier: ViewModifier {
|
|
let color: String?
|
|
|
|
func body(content: Content) -> some View {
|
|
if let color = color {
|
|
content.foregroundStyle(Color(hex: color))
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DebugLog: View {
|
|
#if DEBUG
|
|
private let message: String
|
|
init(_ message: String) {
|
|
self.message = message
|
|
print(message)
|
|
}
|
|
|
|
var body: some View {
|
|
Text(message)
|
|
.font(.caption2)
|
|
.foregroundStyle(.red)
|
|
}
|
|
#else
|
|
init(_: String) {}
|
|
var body: some View { EmptyView() }
|
|
#endif
|
|
}
|
|
|
|
struct LiveActivityView: View {
|
|
let contentState: LiveActivityAttributes.ContentState
|
|
let attributes: LiveActivityAttributes
|
|
@State private var imageContainerSize: CGSize?
|
|
|
|
var progressViewTint: Color? {
|
|
attributes.progressViewTint.map { Color(hex: $0) }
|
|
}
|
|
|
|
private var imageAlignment: Alignment {
|
|
switch attributes.imageAlign {
|
|
case "center":
|
|
return .center
|
|
case "bottom":
|
|
return .bottom
|
|
default:
|
|
return .top
|
|
}
|
|
}
|
|
|
|
private func alignedImage(imageName: String) -> some View {
|
|
let defaultHeight: CGFloat = 64
|
|
let defaultWidth: CGFloat = 64
|
|
let containerHeight = imageContainerSize?.height
|
|
let containerWidth = imageContainerSize?.width
|
|
let hasWidthConstraint = (attributes.imageWidthPercent != nil) || (attributes.imageWidth != nil)
|
|
|
|
let computedHeight: CGFloat? = {
|
|
if let percent = attributes.imageHeightPercent {
|
|
let clamped = min(max(percent, 0), 100) / 100.0
|
|
// Use the row height as a base. Fallback to default when row height is not measured yet.
|
|
let base = (containerHeight ?? defaultHeight)
|
|
return base * clamped
|
|
} else if let size = attributes.imageHeight {
|
|
return CGFloat(size)
|
|
} else if hasWidthConstraint {
|
|
// Mimic CSS: when only width is set, keep height automatic to preserve aspect ratio
|
|
return nil
|
|
} else {
|
|
// Mimic CSS: this works against CSS but provides a better default behavior.
|
|
// When no width/height is set, use a default size (64pt)
|
|
// Width will adjust automatically base on aspect ratio
|
|
return defaultHeight
|
|
}
|
|
}()
|
|
|
|
let computedWidth: CGFloat? = {
|
|
if let percent = attributes.imageWidthPercent {
|
|
let clamped = min(max(percent, 0), 100) / 100.0
|
|
let base = (containerWidth ?? defaultWidth)
|
|
return base * clamped
|
|
} else if let size = attributes.imageWidth {
|
|
return CGFloat(size)
|
|
} else {
|
|
return nil // Keep aspect fit based on height
|
|
}
|
|
}()
|
|
|
|
return ZStack(alignment: .center) {
|
|
Group {
|
|
let fit = attributes.contentFit ?? "cover"
|
|
switch fit {
|
|
case "contain":
|
|
Image.dynamic(assetNameOrPath: imageName).resizable().scaledToFit().frame(width: computedWidth, height: computedHeight)
|
|
case "fill":
|
|
Image.dynamic(assetNameOrPath: imageName).resizable().frame(
|
|
width: computedWidth,
|
|
height: computedHeight
|
|
)
|
|
case "none":
|
|
Image.dynamic(assetNameOrPath: imageName).renderingMode(.original).frame(width: computedWidth, height: computedHeight)
|
|
case "scale-down":
|
|
if let uiImage = UIImage.dynamic(assetNameOrPath: imageName) {
|
|
// Determine the target box. When width/height are nil, we use image's intrinsic dimension for comparison.
|
|
let targetHeight = computedHeight ?? uiImage.size.height
|
|
let targetWidth = computedWidth ?? uiImage.size.width
|
|
let shouldScaleDown = uiImage.size.height > targetHeight || uiImage.size.width > targetWidth
|
|
|
|
if shouldScaleDown {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: computedWidth, height: computedHeight)
|
|
} else {
|
|
Image(uiImage: uiImage)
|
|
.renderingMode(.original)
|
|
.frame(width: min(uiImage.size.width, targetWidth), height: min(uiImage.size.height, targetHeight))
|
|
}
|
|
} else {
|
|
DebugLog("⚠️[ExpoLiveActivity] assetNameOrPath couldn't resolve to UIImage")
|
|
}
|
|
case "cover":
|
|
Image.dynamic(assetNameOrPath: imageName).resizable().scaledToFill().frame(
|
|
width: computedWidth,
|
|
height: computedHeight
|
|
).clipped()
|
|
default:
|
|
DebugLog("⚠️[ExpoLiveActivity] Unknown contentFit '\(fit)'")
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: imageAlignment)
|
|
.background(
|
|
GeometryReader { proxy in
|
|
Color.clear
|
|
.onAppear {
|
|
let s = proxy.size
|
|
if s.width > 0, s.height > 0 { imageContainerSize = s }
|
|
}
|
|
.onChange(of: proxy.size) { s in
|
|
if s.width > 0, s.height > 0 { imageContainerSize = s }
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
let defaultPadding = 24
|
|
|
|
let top = CGFloat(
|
|
attributes.paddingDetails?.top
|
|
?? attributes.paddingDetails?.vertical
|
|
?? attributes.padding
|
|
?? defaultPadding
|
|
)
|
|
|
|
let bottom = CGFloat(
|
|
attributes.paddingDetails?.bottom
|
|
?? attributes.paddingDetails?.vertical
|
|
?? attributes.padding
|
|
?? defaultPadding
|
|
)
|
|
|
|
let leading = CGFloat(
|
|
attributes.paddingDetails?.left
|
|
?? attributes.paddingDetails?.horizontal
|
|
?? attributes.padding
|
|
?? defaultPadding
|
|
)
|
|
|
|
let trailing = CGFloat(
|
|
attributes.paddingDetails?.right
|
|
?? attributes.paddingDetails?.horizontal
|
|
?? attributes.padding
|
|
?? defaultPadding
|
|
)
|
|
|
|
VStack(alignment: .leading) {
|
|
let position = attributes.imagePosition ?? "right"
|
|
let isStretch = position.contains("Stretch")
|
|
let isLeftImage = position.hasPrefix("left")
|
|
let hasImage = contentState.imageName != nil
|
|
let effectiveStretch = isStretch && hasImage
|
|
|
|
HStack(alignment: .center) {
|
|
if hasImage, isLeftImage {
|
|
if let imageName = contentState.imageName {
|
|
alignedImage(imageName: imageName)
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(contentState.title)
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
.modifier(ConditionalForegroundViewModifier(color: attributes.titleColor))
|
|
|
|
if let subtitle = contentState.subtitle {
|
|
Text(subtitle)
|
|
.font(.title3)
|
|
.modifier(ConditionalForegroundViewModifier(color: attributes.subtitleColor))
|
|
}
|
|
|
|
if effectiveStretch {
|
|
if let date = contentState.timerEndDateInMilliseconds {
|
|
ProgressView(timerInterval: Date.toTimerInterval(miliseconds: date))
|
|
.tint(progressViewTint)
|
|
.modifier(ConditionalForegroundViewModifier(color: attributes.progressViewLabelColor))
|
|
} else if let progress = contentState.progress {
|
|
ProgressView(value: progress)
|
|
.tint(progressViewTint)
|
|
.modifier(ConditionalForegroundViewModifier(color: attributes.progressViewLabelColor))
|
|
}
|
|
}
|
|
}.layoutPriority(1)
|
|
|
|
if hasImage, !isLeftImage { // right side (default)
|
|
Spacer()
|
|
if let imageName = contentState.imageName {
|
|
alignedImage(imageName: imageName)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !effectiveStretch {
|
|
if let date = contentState.timerEndDateInMilliseconds {
|
|
ProgressView(timerInterval: Date.toTimerInterval(miliseconds: date))
|
|
.tint(progressViewTint)
|
|
.modifier(ConditionalForegroundViewModifier(color: attributes.progressViewLabelColor))
|
|
} else if let progress = contentState.progress {
|
|
ProgressView(value: progress)
|
|
.tint(progressViewTint)
|
|
.modifier(ConditionalForegroundViewModifier(color: attributes.progressViewLabelColor))
|
|
}
|
|
}
|
|
}
|
|
.padding(EdgeInsets(top: top, leading: leading, bottom: bottom, trailing: trailing))
|
|
}
|
|
}
|
|
|
|
#endif
|