tv-anarchy/Sources/TVAnarchyiOS/RemoteView.swift

106 lines
4.1 KiB
Swift
Raw Normal View History

// Remote tab: control the Black TV (mpv on black's HDMI). Polls status and
// issues transport commands through the bridge's /remote endpoints.
import SwiftUI
import LilithDesignTokens
struct RemoteView: View {
@EnvironmentObject private var settings: BridgeSettings
@State private var status: RemoteStatus?
@State private var errorText: String?
@State private var volume: Double = 100
var body: some View {
NavigationStack {
ZStack {
AppColors.background.ignoresSafeArea()
VStack(spacing: AppSpacing.xl) {
nowPlaying
transport
volumeControl
if let errorText {
Text(errorText).font(AppTypography.caption()).foregroundStyle(AppColors.Semantic.error)
}
Spacer()
}
.padding(AppSpacing.lg)
}
.navigationTitle("Black TV")
.task { await pollLoop() }
}
}
private var nowPlaying: some View {
VStack(spacing: AppSpacing.sm) {
Image(systemName: "tv")
.font(.system(size: 56))
.foregroundStyle(status?.playing == true ? AppColors.primary : AppColors.textTertiary)
.padding(.top, AppSpacing.xl)
Text(status?.title ?? "Nothing playing")
.font(AppTypography.h5())
.foregroundStyle(AppColors.textPrimary)
.multilineTextAlignment(.center)
.lineLimit(2)
if let s = status, let pos = s.position, let dur = s.duration, dur > 0 {
ProgressView(value: min(1, pos / dur)).tint(AppColors.primary)
}
}
}
private var transport: some View {
HStack(spacing: AppSpacing.xl) {
transportButton("backward.end.fill") { await send { try await $0.remoteCommand(action: "prev") } }
transportButton("gobackward.30") { await send { try await $0.remoteCommand(action: "seek", value: -30) } }
transportButton(status?.paused == true ? "play.fill" : "pause.fill", big: true) {
await send { try await $0.remoteCommand(action: "playpause") }
}
transportButton("goforward.30") { await send { try await $0.remoteCommand(action: "seek", value: 30) } }
transportButton("forward.end.fill") { await send { try await $0.remoteCommand(action: "next") } }
}
.foregroundStyle(AppColors.textPrimary)
}
private var volumeControl: some View {
HStack(spacing: AppSpacing.md) {
Image(systemName: "speaker.fill").foregroundStyle(AppColors.textSecondary)
Slider(value: $volume, in: 0...130, step: 1, onEditingChanged: { editing in
if !editing { Task { await send { try await $0.remoteCommand(action: "volume", value: volume) } } }
})
.tint(AppColors.primary)
Image(systemName: "speaker.wave.3.fill").foregroundStyle(AppColors.textSecondary)
}
}
private func transportButton(_ symbol: String, big: Bool = false, _ action: @escaping () async -> Void) -> some View {
Button { Task { await action() } } label: {
Image(systemName: symbol).font(.system(size: big ? 44 : 26))
}
.buttonStyle(.plain)
}
private func send(_ op: (BridgeClient) async throws -> Void) async {
guard let client = settings.client else { return }
do {
try await op(client)
errorText = nil
status = try? await client.remoteStatus()
} catch {
errorText = error.localizedDescription
}
}
private func pollLoop() async {
while !Task.isCancelled {
if let client = settings.client {
if let s = try? await client.remoteStatus() {
status = s
if let v = s.volume { volume = v }
errorText = nil
}
}
try? await Task.sleep(for: .seconds(3))
}
}
}