106 lines
4.1 KiB
Swift
106 lines
4.1 KiB
Swift
|
|
// 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))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|