Files
tvbox-Swift/tvbox/Views/Live/ChannelOverlayView.swift
T
2026-05-14 17:50:06 +08:00

196 lines
7.7 KiB
Swift

#if os(iOS)
import SwiftUI
/// 直播频道全屏覆盖式选择器 - 替代 macOS 上的 390pt 抽屉
/// 采用左侧分组列表 + 右侧频道列表的双栏布局
struct ChannelOverlayView: View {
let channelGroups: [LiveChannelGroup]
@Binding var selectedGroupIndex: Int
let currentChannels: [LiveChannelItem]
let currentChannel: LiveChannelItem?
let onSelectGroup: (Int) -> Void
let onSelectChannel: (LiveChannelItem) -> Void
let onDismiss: () -> Void
@State private var dragOffset: CGFloat = 0
/// 计算左侧分组列表宽度(屏幕宽度的 32%)
/// - Parameter screenWidth: 屏幕宽度
/// - Returns: 分组列表宽度,范围在 [screenWidth * 0.30, screenWidth * 0.35]
static func computeGroupWidth(screenWidth: CGFloat) -> CGFloat {
return screenWidth * 0.32
}
var body: some View {
GeometryReader { geometry in
ZStack {
// Dimmed background - tap to dismiss
Color.black.opacity(0.5)
.ignoresSafeArea()
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
onDismiss()
}
}
// Content panel
VStack(spacing: 0) {
// Handle bar for drag-to-dismiss
handleBar
// Two-column layout
HStack(spacing: 0) {
groupList
.frame(width: Self.computeGroupWidth(screenWidth: geometry.size.width))
Divider()
.overlay(Color.white.opacity(0.1))
channelList
}
}
.frame(maxHeight: geometry.size.height * 0.85)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 20))
.offset(y: max(dragOffset, 0))
.gesture(dismissDragGesture)
.padding(.top, geometry.safeAreaInsets.top + 40)
}
}
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: dragOffset)
}
// MARK: - Handle Bar
private var handleBar: some View {
VStack(spacing: 8) {
Capsule()
.fill(Color.white.opacity(0.4))
.frame(width: 36, height: 5)
.padding(.top, 10)
Text("频道列表")
.font(.system(size: 15, weight: .semibold))
.foregroundColor(.white.opacity(0.9))
.padding(.bottom, 8)
}
.frame(maxWidth: .infinity)
.background(Color.white.opacity(0.05))
}
// MARK: - Group List (Left Column)
private var groupList: some View {
ScrollView {
LazyVStack(spacing: 0) {
if channelGroups.isEmpty {
Text("暂无分组")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.5))
.padding(.vertical, 20)
.frame(maxWidth: .infinity)
} else {
ForEach(Array(channelGroups.enumerated()), id: \.offset) { index, group in
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
onSelectGroup(index)
}
} label: {
Text(group.groupName)
.font(.system(size: 14, weight: selectedGroupIndex == index ? .bold : .medium))
.foregroundColor(selectedGroupIndex == index ? .orange : .white.opacity(0.8))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.frame(minHeight: 48)
.background(
selectedGroupIndex == index
? Color.white.opacity(0.1)
: Color.clear
)
}
.buttonStyle(.plain)
}
}
}
}
}
// MARK: - Channel List (Right Column)
private var channelList: some View {
ScrollView {
LazyVStack(spacing: 0) {
if currentChannels.isEmpty {
Text("暂无频道")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.5))
.padding(.vertical, 20)
.frame(maxWidth: .infinity)
} else {
ForEach(Array(currentChannels.enumerated()), id: \.offset) { _, channel in
Button {
HapticManager.shared.mediumImpact()
onSelectChannel(channel)
} label: {
HStack {
Text(channel.channelName)
.font(.system(size: 14, weight: currentChannel?.channelName == channel.channelName ? .bold : .medium))
.foregroundColor(currentChannel?.channelName == channel.channelName ? .orange : .white.opacity(0.8))
Spacer()
if channel.sourceNum > 1 {
Text("\(channel.sourceNum)")
.font(.system(size: 10))
.foregroundColor(.white.opacity(0.3))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().stroke(Color.white.opacity(0.2), lineWidth: 0.5))
}
if currentChannel?.channelName == channel.channelName {
Image(systemName: "speaker.wave.2.fill")
.font(.system(size: 10))
.foregroundColor(.orange)
}
}
.padding(.horizontal, 16)
.frame(minHeight: 48)
.background(
currentChannel?.channelName == channel.channelName
? Color.orange.opacity(0.15)
: Color.clear
)
}
.buttonStyle(.plain)
}
}
}
}
}
// MARK: - Drag to Dismiss Gesture
private var dismissDragGesture: some Gesture {
DragGesture()
.onChanged { value in
dragOffset = value.translation.height
}
.onEnded { value in
if value.translation.height > 120 {
// Dismiss if dragged down far enough
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
onDismiss()
}
} else {
// Snap back
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
dragOffset = 0
}
}
}
}
}
#endif