// The host grid's cards: a saved host (tap to connect, context menu) and an mDNS-discovered // host (tap to save + connect). Both share the same platform-tuned sizing. import PunktfunkKit import SwiftUI /// Shared host-card sizing — touch-first on iOS, compact on macOS/tvOS. private struct CardMetrics { let iconSize: CGFloat let iconBox: CGFloat let cardPadding: CGFloat let nameFont: Font static var current: CardMetrics { #if os(iOS) CardMetrics(iconSize: 56, iconBox: 76, cardPadding: 28, nameFont: .title3.weight(.semibold)) #else CardMetrics(iconSize: 42, iconBox: 56, cardPadding: 18, nameFont: .headline) #endif } } /// A saved host. The accent ring marks the most-recently-connected one; the context menu /// pairs / speed-tests / forgets / removes. Disabled while a session is busy. struct HostCardView: View { let host: StoredHost /// Currently advertising on the LAN (matched against live mDNS discovery). False means /// "not seen on this network" — off, or a remote/cross-subnet host we can't observe. let isOnline: Bool let isConnecting: Bool let isMostRecent: Bool let isBusy: Bool let onConnect: () -> Void let onPair: () -> Void let onSpeedTest: () -> Void let onForget: () -> Void let onRemove: () -> Void /// Open the experimental library browser — nil (no menu item) unless the feature flag is on. var onBrowseLibrary: (() -> Void)? = nil var body: some View { let m = CardMetrics.current return Button(action: onConnect) { VStack(spacing: 10) { ZStack { Image(systemName: "play.display") .font(.system(size: m.iconSize, weight: .light)) .foregroundStyle(.tint) .opacity(isConnecting ? 0.3 : 1) if isConnecting { ProgressView() } } .frame(height: m.iconBox) VStack(spacing: 2) { HStack(spacing: 6) { // Presence dot: green = advertising on the LAN now; grey = not seen. Circle() .fill(isOnline ? Color.green : Color.secondary.opacity(0.35)) .frame(width: 7, height: 7) .accessibilityLabel(isOnline ? "Online" : "Offline") Text(host.displayName) .font(m.nameFont) .lineLimit(1) } HStack(spacing: 4) { if host.pinnedSHA256 != nil { Image(systemName: "lock.fill") .font(.system(size: 9)) .foregroundStyle(.secondary) } Text("\(host.address):\(String(host.port))") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } if let last = host.lastConnected { Text("Connected \(last, format: .relative(presentation: .named))") .font(.caption2) .foregroundStyle(.tertiary) .lineLimit(1) } } } .frame(maxWidth: .infinity) .padding(.vertical, m.cardPadding) .padding(.horizontal, 12) #if !os(tvOS) // tvOS: the .card button style owns platter + focus motion — extra chrome // inside it mutes the grow/tilt. Material + accent ring are for pointer UIs. .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) .overlay { if isMostRecent { RoundedRectangle(cornerRadius: 14) .strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5) } } #endif } #if os(tvOS) .buttonStyle(.card) #else .buttonStyle(.plain) #endif .disabled(isBusy) .contextMenu { Button("Pair with PIN…", action: onPair) Button("Test Network Speed…", action: onSpeedTest) if let onBrowseLibrary { Button("Browse Library…", action: onBrowseLibrary) } if host.pinnedSHA256 != nil { // Dropping the pin does NOT downgrade to TOFU: the next connect must re-pair via // PIN (unless the host advertises pair=optional). Wording reflects that. Button("Forget Identity (re-pair to reconnect)", action: onForget) } Button("Remove", role: .destructive, action: onRemove) } } } /// A host found on the LAN but not yet saved. A dashed ring distinguishes it from saved cards; /// tapping saves it and connects (or pairs, if the host requires it). struct DiscoveredCardView: View { let discovered: DiscoveredHost let isBusy: Bool let onConnect: () -> Void var body: some View { let m = CardMetrics.current return Button(action: onConnect) { VStack(spacing: 10) { Image(systemName: "play.display") .font(.system(size: m.iconSize, weight: .light)) .foregroundStyle(.tint) .frame(height: m.iconBox) VStack(spacing: 2) { Text(discovered.name) .font(m.nameFont) .lineLimit(1) HStack(spacing: 4) { Image(systemName: discovered.requiresPairing ? "lock.fill" : "wifi") .font(.system(size: 9)) .foregroundStyle(.secondary) Text("\(discovered.host):\(String(discovered.port))") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } Text(discovered.requiresPairing ? "Pairing required" : "Discovered") .font(.caption2) .foregroundStyle(.tertiary) } } .frame(maxWidth: .infinity) .padding(.vertical, m.cardPadding) .padding(.horizontal, 12) #if !os(tvOS) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) .overlay { RoundedRectangle(cornerRadius: 14) .strokeBorder( Color.secondary.opacity(0.25), style: StrokeStyle(lineWidth: 1, dash: [4, 3])) } #endif } #if os(tvOS) .buttonStyle(.card) #else .buttonStyle(.plain) #endif .disabled(isBusy) } }