mirror of
https://github.com/yattee/yattee.git
synced 2026-02-21 02:09:46 +00:00
Yattee v2 rewrite
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
//
|
||||
// PanscanGestureSettingsView.swift
|
||||
// Yattee
|
||||
//
|
||||
// Settings view for configuring pinch-to-panscan gesture.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
/// Settings view for configuring pinch-to-panscan gesture on the player.
|
||||
struct PanscanGestureSettingsView: View {
|
||||
@Bindable var viewModel: PlayerControlsSettingsViewModel
|
||||
|
||||
// Local state for immediate UI updates
|
||||
@State private var isEnabled: Bool = true
|
||||
@State private var snapToEnds: Bool = true
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
enableSection
|
||||
if isEnabled {
|
||||
snapModeSection
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "gestures.panscan.title", defaultValue: "Panscan Gesture"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
syncFromViewModel()
|
||||
}
|
||||
.onChange(of: viewModel.activePreset?.id) { _, _ in
|
||||
syncFromViewModel()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var enableSection: some View {
|
||||
Section {
|
||||
Toggle(
|
||||
String(localized: "gestures.panscan.enable", defaultValue: "Enable Panscan Gesture"),
|
||||
isOn: $isEnabled
|
||||
)
|
||||
.onChange(of: isEnabled) { _, newValue in
|
||||
viewModel.updatePanscanGestureSettingsSync { $0.isEnabled = newValue }
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
} footer: {
|
||||
Text(String(localized: "gestures.panscan.enableFooter", defaultValue: "Pinch to zoom between fit and fill modes while in fullscreen."))
|
||||
}
|
||||
}
|
||||
|
||||
private var snapModeSection: some View {
|
||||
Section {
|
||||
Toggle(
|
||||
String(localized: "gestures.panscan.snapToEnds", defaultValue: "Snap to Fit/Fill"),
|
||||
isOn: $snapToEnds
|
||||
)
|
||||
.onChange(of: snapToEnds) { _, newValue in
|
||||
viewModel.updatePanscanGestureSettingsSync { $0.snapToEnds = newValue }
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
} footer: {
|
||||
Text(snapModeFooterText)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var snapModeFooterText: String {
|
||||
if snapToEnds {
|
||||
String(localized: "gestures.panscan.snapToEnds.on.footer", defaultValue: "When released, the zoom will snap to either fit (show full video) or fill (fill the screen).")
|
||||
} else {
|
||||
String(localized: "gestures.panscan.snapToEnds.off.footer", defaultValue: "The zoom level stays exactly where you release, allowing any value between fit and fill.")
|
||||
}
|
||||
}
|
||||
|
||||
private func syncFromViewModel() {
|
||||
let settings = viewModel.panscanGestureSettings
|
||||
isEnabled = settings.isEnabled
|
||||
snapToEnds = settings.snapToEnds
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
PanscanGestureSettingsView(
|
||||
viewModel: PlayerControlsSettingsViewModel(
|
||||
layoutService: PlayerControlsLayoutService(),
|
||||
settingsManager: SettingsManager()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,111 @@
|
||||
//
|
||||
// SeekGestureSettingsView.swift
|
||||
// Yattee
|
||||
//
|
||||
// Settings view for configuring horizontal seek gesture.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
/// Settings view for configuring horizontal seek gesture on the player.
|
||||
struct SeekGestureSettingsView: View {
|
||||
@Bindable var viewModel: PlayerControlsSettingsViewModel
|
||||
|
||||
// Local state for immediate UI updates
|
||||
@State private var isEnabled: Bool = false
|
||||
@State private var sensitivity: SeekGestureSensitivity = .medium
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
enableSection
|
||||
if isEnabled {
|
||||
sensitivitySection
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "gestures.seek.title", defaultValue: "Seek Gesture"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
syncFromViewModel()
|
||||
}
|
||||
.onChange(of: viewModel.activePreset?.id) { _, _ in
|
||||
syncFromViewModel()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var enableSection: some View {
|
||||
Section {
|
||||
Toggle(
|
||||
String(localized: "gestures.seek.enable", defaultValue: "Enable Seek Gesture"),
|
||||
isOn: $isEnabled
|
||||
)
|
||||
.onChange(of: isEnabled) { _, newValue in
|
||||
viewModel.updateSeekGestureSettingsSync { $0.isEnabled = newValue }
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
} footer: {
|
||||
Text(String(localized: "gestures.seek.enableFooter", defaultValue: "Drag left or right to seek backward or forward when controls are hidden."))
|
||||
}
|
||||
}
|
||||
|
||||
private var sensitivitySection: some View {
|
||||
Section {
|
||||
Picker(
|
||||
String(localized: "gestures.seek.sensitivity", defaultValue: "Sensitivity"),
|
||||
selection: $sensitivity
|
||||
) {
|
||||
ForEach(SeekGestureSensitivity.allCases, id: \.self) { level in
|
||||
VStack(alignment: .leading) {
|
||||
Text(level.displayName)
|
||||
}
|
||||
.tag(level)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
.labelsHidden()
|
||||
.onChange(of: sensitivity) { _, newValue in
|
||||
viewModel.updateSeekGestureSettingsSync { $0.sensitivity = newValue }
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
} header: {
|
||||
Text(String(localized: "gestures.seek.sensitivity", defaultValue: "Sensitivity"))
|
||||
} footer: {
|
||||
Text(sensitivityFooterText)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var sensitivityFooterText: String {
|
||||
switch sensitivity {
|
||||
case .low:
|
||||
String(localized: "gestures.seek.sensitivity.low.footer", defaultValue: "Precise control for short videos or fine-tuning.")
|
||||
case .medium:
|
||||
String(localized: "gestures.seek.sensitivity.medium.footer", defaultValue: "Balanced for most video lengths.")
|
||||
case .high:
|
||||
String(localized: "gestures.seek.sensitivity.high.footer", defaultValue: "Fast navigation for long videos or podcasts.")
|
||||
}
|
||||
}
|
||||
|
||||
private func syncFromViewModel() {
|
||||
let settings = viewModel.seekGestureSettings
|
||||
isEnabled = settings.isEnabled
|
||||
sensitivity = settings.sensitivity
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
SeekGestureSettingsView(
|
||||
viewModel: PlayerControlsSettingsViewModel(
|
||||
layoutService: PlayerControlsLayoutService(),
|
||||
settingsManager: SettingsManager()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,243 @@
|
||||
//
|
||||
// TapGesturesSettingsView.swift
|
||||
// Yattee
|
||||
//
|
||||
// Settings view for configuring tap gestures.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
/// Settings view for configuring tap gestures on the player.
|
||||
struct TapGesturesSettingsView: View {
|
||||
@Bindable var viewModel: PlayerControlsSettingsViewModel
|
||||
|
||||
// Local state for immediate UI updates
|
||||
@State private var isEnabled: Bool = false
|
||||
@State private var layout: TapZoneLayout = .horizontalSplit
|
||||
@State private var doubleTapInterval: Int = 300
|
||||
@State private var zoneConfigurations: [TapZoneConfiguration] = []
|
||||
|
||||
// Navigation state
|
||||
@State private var selectedZonePosition: TapZonePosition?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
enableSection
|
||||
if isEnabled {
|
||||
layoutSection
|
||||
previewSection
|
||||
zonesSection
|
||||
timingSection
|
||||
}
|
||||
}
|
||||
.navigationTitle(String(localized: "gestures.tap.title", defaultValue: "Tap Gestures"))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.onAppear {
|
||||
syncFromViewModel()
|
||||
}
|
||||
.onChange(of: viewModel.activePreset?.id) { _, _ in
|
||||
syncFromViewModel()
|
||||
}
|
||||
.sheet(item: $selectedZonePosition) { position in
|
||||
NavigationStack {
|
||||
zoneActionPicker(for: position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var enableSection: some View {
|
||||
Section {
|
||||
Toggle(
|
||||
String(localized: "gestures.tap.enable", defaultValue: "Enable Tap Gestures"),
|
||||
isOn: $isEnabled
|
||||
)
|
||||
.onChange(of: isEnabled) { _, newValue in
|
||||
viewModel.updateTapGesturesSettingsSync { $0.isEnabled = newValue }
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
} footer: {
|
||||
Text(String(localized: "gestures.tap.enableFooter", defaultValue: "Double-tap zones on the player to trigger actions when controls are hidden."))
|
||||
}
|
||||
}
|
||||
|
||||
private var layoutSection: some View {
|
||||
Section {
|
||||
TapZoneLayoutPicker(selectedLayout: $layout)
|
||||
.onChange(of: layout) { _, newLayout in
|
||||
// Update configurations for new layout
|
||||
zoneConfigurations = TapGesturesSettings.defaultConfigurations(for: newLayout)
|
||||
viewModel.updateTapGesturesSettingsSync { settings in
|
||||
settings = settings.withLayout(newLayout)
|
||||
}
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
} header: {
|
||||
Text(String(localized: "gestures.tap.layout", defaultValue: "Zone Layout"))
|
||||
} footer: {
|
||||
Text(String(localized: "gestures.tap.layoutFooter", defaultValue: "Choose how the screen is divided into tap zones."))
|
||||
}
|
||||
}
|
||||
|
||||
private var previewSection: some View {
|
||||
Section {
|
||||
TapZonePreview(
|
||||
layout: layout,
|
||||
configurations: zoneConfigurations,
|
||||
onZoneTapped: { position in
|
||||
if viewModel.canEditActivePreset {
|
||||
selectedZonePosition = position
|
||||
}
|
||||
}
|
||||
)
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||
} header: {
|
||||
Text(String(localized: "gestures.tap.preview", defaultValue: "Preview"))
|
||||
} footer: {
|
||||
Text(String(localized: "gestures.tap.previewFooter", defaultValue: "Tap a zone to configure its action."))
|
||||
}
|
||||
}
|
||||
|
||||
private var zonesSection: some View {
|
||||
Section {
|
||||
ForEach(layout.positions, id: \.self) { position in
|
||||
Button {
|
||||
if viewModel.canEditActivePreset {
|
||||
selectedZonePosition = position
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(position.displayName)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let config = zoneConfigurations.first(where: { $0.position == position }) {
|
||||
Label {
|
||||
Text(actionSummary(for: config.action))
|
||||
} icon: {
|
||||
Image(systemName: config.action.systemImage)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "gestures.tap.zones", defaultValue: "Zone Actions"))
|
||||
}
|
||||
}
|
||||
|
||||
private var timingSection: some View {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(String(localized: "gestures.tap.doubleTapWindow", defaultValue: "Double-Tap Window"))
|
||||
Spacer()
|
||||
Text("\(doubleTapInterval)ms")
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { Double(doubleTapInterval) },
|
||||
set: {
|
||||
doubleTapInterval = Int($0)
|
||||
viewModel.updateTapGesturesSettingsSync { $0.doubleTapInterval = doubleTapInterval }
|
||||
}
|
||||
),
|
||||
in: Double(TapGesturesSettings.doubleTapIntervalRange.lowerBound)...Double(TapGesturesSettings.doubleTapIntervalRange.upperBound),
|
||||
step: 25
|
||||
)
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "gestures.tap.timing", defaultValue: "Timing"))
|
||||
} footer: {
|
||||
Text(String(localized: "gestures.tap.timingFooter", defaultValue: "Time window to detect a double-tap. Lower values are faster but may conflict with single-tap."))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@ViewBuilder
|
||||
private func zoneActionPicker(for position: TapZonePosition) -> some View {
|
||||
let binding = Binding<TapGestureAction>(
|
||||
get: {
|
||||
zoneConfigurations.first { $0.position == position }?.action ?? .togglePlayPause
|
||||
},
|
||||
set: { newAction in
|
||||
if let index = zoneConfigurations.firstIndex(where: { $0.position == position }) {
|
||||
zoneConfigurations[index] = zoneConfigurations[index].withAction(newAction)
|
||||
viewModel.updateTapZoneConfigurationSync(zoneConfigurations[index])
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
TapZoneActionPicker(position: position, action: binding)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(role: .cancel) {
|
||||
selectedZonePosition = nil
|
||||
} label: {
|
||||
Label("Close", systemImage: "xmark")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func syncFromViewModel() {
|
||||
let settings = viewModel.gesturesSettings.tapGestures
|
||||
isEnabled = settings.isEnabled
|
||||
layout = settings.layout
|
||||
doubleTapInterval = settings.doubleTapInterval
|
||||
zoneConfigurations = settings.zoneConfigurations
|
||||
}
|
||||
|
||||
private func actionSummary(for action: TapGestureAction) -> String {
|
||||
switch action {
|
||||
case .seekForward(let seconds):
|
||||
"+\(seconds)s"
|
||||
case .seekBackward(let seconds):
|
||||
"-\(seconds)s"
|
||||
case .togglePlayPause:
|
||||
String(localized: "gestures.action.playPause.short", defaultValue: "Play/Pause")
|
||||
case .toggleFullscreen:
|
||||
String(localized: "gestures.action.fullscreen.short", defaultValue: "Fullscreen")
|
||||
case .togglePiP:
|
||||
String(localized: "gestures.action.pip.short", defaultValue: "PiP")
|
||||
case .playNext:
|
||||
String(localized: "gestures.action.next.short", defaultValue: "Next")
|
||||
case .playPrevious:
|
||||
String(localized: "gestures.action.previous.short", defaultValue: "Previous")
|
||||
case .cyclePlaybackSpeed:
|
||||
String(localized: "gestures.action.speed.short", defaultValue: "Speed")
|
||||
case .toggleMute:
|
||||
String(localized: "gestures.action.mute.short", defaultValue: "Mute")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
TapGesturesSettingsView(
|
||||
viewModel: PlayerControlsSettingsViewModel(
|
||||
layoutService: PlayerControlsLayoutService(),
|
||||
settingsManager: SettingsManager()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,122 @@
|
||||
//
|
||||
// TapZoneActionPicker.swift
|
||||
// Yattee
|
||||
//
|
||||
// Picker for selecting and configuring a tap zone action.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
/// View for selecting and configuring a tap zone's action.
|
||||
struct TapZoneActionPicker: View {
|
||||
let position: TapZonePosition
|
||||
@Binding var action: TapGestureAction
|
||||
|
||||
@State private var selectedActionType: TapGestureActionType
|
||||
@State private var seekSeconds: Int
|
||||
|
||||
init(position: TapZonePosition, action: Binding<TapGestureAction>) {
|
||||
self.position = position
|
||||
self._action = action
|
||||
self._selectedActionType = State(initialValue: action.wrappedValue.actionType)
|
||||
self._seekSeconds = State(initialValue: action.wrappedValue.seekSeconds ?? 10)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(TapGestureActionType.allCases) { actionType in
|
||||
Button {
|
||||
selectedActionType = actionType
|
||||
updateAction()
|
||||
} label: {
|
||||
HStack {
|
||||
Label {
|
||||
Text(actionType.displayName)
|
||||
} icon: {
|
||||
Image(systemName: actionType.systemImage)
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedActionType == actionType {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: "gestures.tap.selectAction", defaultValue: "Select Action"))
|
||||
}
|
||||
|
||||
if selectedActionType.requiresSecondsParameter {
|
||||
Section {
|
||||
seekSecondsControl
|
||||
} header: {
|
||||
Text(String(localized: "gestures.tap.seekDuration", defaultValue: "Seek Duration"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(position.displayName)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var seekSecondsControl: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text(String(localized: "gestures.tap.seconds", defaultValue: "Seconds"))
|
||||
Spacer()
|
||||
Text("\(seekSeconds)s")
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { Double(seekSeconds) },
|
||||
set: {
|
||||
seekSeconds = Int($0)
|
||||
updateAction()
|
||||
}
|
||||
),
|
||||
in: 1...90,
|
||||
step: 1
|
||||
)
|
||||
|
||||
// Quick presets
|
||||
HStack(spacing: 8) {
|
||||
ForEach([5, 10, 15, 30, 45, 60], id: \.self) { seconds in
|
||||
Button("\(seconds)s") {
|
||||
seekSeconds = seconds
|
||||
updateAction()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(seekSeconds == seconds ? .accentColor : .secondary)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAction() {
|
||||
action = selectedActionType.toAction(seconds: seekSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
TapZoneActionPicker(
|
||||
position: .left,
|
||||
action: .constant(.seekBackward(seconds: 10))
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,148 @@
|
||||
//
|
||||
// TapZoneLayoutPicker.swift
|
||||
// Yattee
|
||||
//
|
||||
// Visual picker for selecting tap zone layouts.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Visual grid picker for selecting a tap zone layout.
|
||||
struct TapZoneLayoutPicker: View {
|
||||
@Binding var selectedLayout: TapZoneLayout
|
||||
|
||||
private let layouts = TapZoneLayout.allCases
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 70, maximum: 90), spacing: 12)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: columns, spacing: 12) {
|
||||
ForEach(layouts) { layout in
|
||||
LayoutOption(
|
||||
layout: layout,
|
||||
isSelected: selectedLayout == layout
|
||||
)
|
||||
.onTapGesture {
|
||||
selectedLayout = layout
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout Option
|
||||
|
||||
private struct LayoutOption: View {
|
||||
let layout: TapZoneLayout
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(isSelected ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
|
||||
)
|
||||
|
||||
LayoutPreviewMiniature(layout: layout)
|
||||
.padding(6)
|
||||
}
|
||||
.frame(width: 70, height: 50)
|
||||
|
||||
Text(layout.layoutDescription)
|
||||
.font(.caption2)
|
||||
.fontWeight(isSelected ? .semibold : .regular)
|
||||
.foregroundStyle(isSelected ? .primary : .secondary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout Preview Miniature
|
||||
|
||||
private struct LayoutPreviewMiniature: View {
|
||||
let layout: TapZoneLayout
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let size = geometry.size
|
||||
|
||||
switch layout {
|
||||
case .single:
|
||||
singleZone(size: size)
|
||||
case .horizontalSplit:
|
||||
horizontalSplit(size: size)
|
||||
case .verticalSplit:
|
||||
verticalSplit(size: size)
|
||||
case .threeColumns:
|
||||
threeColumns(size: size)
|
||||
case .quadrants:
|
||||
quadrants(size: size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func singleZone(size: CGSize) -> some View {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.accentColor.opacity(0.4))
|
||||
.frame(width: size.width, height: size.height)
|
||||
}
|
||||
|
||||
private func horizontalSplit(size: CGSize) -> some View {
|
||||
HStack(spacing: 2) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.accentColor.opacity(0.4))
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.accentColor.opacity(0.6))
|
||||
}
|
||||
}
|
||||
|
||||
private func verticalSplit(size: CGSize) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.accentColor.opacity(0.4))
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.accentColor.opacity(0.6))
|
||||
}
|
||||
}
|
||||
|
||||
private func threeColumns(size: CGSize) -> some View {
|
||||
HStack(spacing: 2) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.accentColor.opacity(0.4))
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.accentColor.opacity(0.5))
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.accentColor.opacity(0.6))
|
||||
}
|
||||
}
|
||||
|
||||
private func quadrants(size: CGSize) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
HStack(spacing: 2) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.accentColor.opacity(0.4))
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.accentColor.opacity(0.5))
|
||||
}
|
||||
HStack(spacing: 2) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.accentColor.opacity(0.5))
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.accentColor.opacity(0.6))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
Form {
|
||||
Section("Layout") {
|
||||
TapZoneLayoutPicker(selectedLayout: .constant(.horizontalSplit))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//
|
||||
// TapZonePreview.swift
|
||||
// Yattee
|
||||
//
|
||||
// Interactive preview showing tap zones that can be tapped to configure.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Interactive preview of tap zones. Tapping a zone opens its configuration.
|
||||
struct TapZonePreview: View {
|
||||
let layout: TapZoneLayout
|
||||
let configurations: [TapZoneConfiguration]
|
||||
let onZoneTapped: (TapZonePosition) -> Void
|
||||
|
||||
@State private var tappedZone: TapZonePosition?
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// Background
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.black)
|
||||
|
||||
// Zone overlays
|
||||
switch layout {
|
||||
case .single:
|
||||
singleLayout(size: geometry.size)
|
||||
case .horizontalSplit:
|
||||
horizontalSplitLayout(size: geometry.size)
|
||||
case .verticalSplit:
|
||||
verticalSplitLayout(size: geometry.size)
|
||||
case .threeColumns:
|
||||
threeColumnsLayout(size: geometry.size)
|
||||
case .quadrants:
|
||||
quadrantsLayout(size: geometry.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 180)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// MARK: - Layouts
|
||||
|
||||
@ViewBuilder
|
||||
private func singleLayout(size: CGSize) -> some View {
|
||||
zoneButton(
|
||||
position: .full,
|
||||
frame: CGRect(origin: .zero, size: size)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func horizontalSplitLayout(size: CGSize) -> some View {
|
||||
HStack(spacing: 2) {
|
||||
zoneButton(position: .left)
|
||||
zoneButton(position: .right)
|
||||
}
|
||||
.padding(2)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func verticalSplitLayout(size: CGSize) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
zoneButton(position: .top)
|
||||
zoneButton(position: .bottom)
|
||||
}
|
||||
.padding(2)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func threeColumnsLayout(size: CGSize) -> some View {
|
||||
HStack(spacing: 2) {
|
||||
zoneButton(position: .leftThird)
|
||||
zoneButton(position: .center)
|
||||
zoneButton(position: .rightThird)
|
||||
}
|
||||
.padding(2)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func quadrantsLayout(size: CGSize) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
HStack(spacing: 2) {
|
||||
zoneButton(position: .topLeft)
|
||||
zoneButton(position: .topRight)
|
||||
}
|
||||
HStack(spacing: 2) {
|
||||
zoneButton(position: .bottomLeft)
|
||||
zoneButton(position: .bottomRight)
|
||||
}
|
||||
}
|
||||
.padding(2)
|
||||
}
|
||||
|
||||
// MARK: - Zone Button
|
||||
|
||||
@ViewBuilder
|
||||
private func zoneButton(
|
||||
position: TapZonePosition,
|
||||
frame: CGRect? = nil
|
||||
) -> some View {
|
||||
let config = configurations.first { $0.position == position }
|
||||
let action = config?.action
|
||||
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
tappedZone = position
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
tappedZone = nil
|
||||
}
|
||||
onZoneTapped(position)
|
||||
}
|
||||
} label: {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.white.opacity(tappedZone == position ? 0.3 : 0.1))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Color.white.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
if let action {
|
||||
Image(systemName: action.systemImage)
|
||||
.font(.title2)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(actionLabel(for: action))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
} else {
|
||||
Image(systemName: "questionmark")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
|
||||
Text(position.displayName)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func actionLabel(for action: TapGestureAction) -> String {
|
||||
switch action {
|
||||
case .seekForward(let seconds):
|
||||
"+\(seconds)s"
|
||||
case .seekBackward(let seconds):
|
||||
"-\(seconds)s"
|
||||
case .togglePlayPause:
|
||||
"Play/Pause"
|
||||
case .toggleFullscreen:
|
||||
"Fullscreen"
|
||||
case .togglePiP:
|
||||
"PiP"
|
||||
case .playNext:
|
||||
"Next"
|
||||
case .playPrevious:
|
||||
"Previous"
|
||||
case .cyclePlaybackSpeed:
|
||||
"Speed"
|
||||
case .toggleMute:
|
||||
"Mute"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
Form {
|
||||
Section {
|
||||
TapZonePreview(
|
||||
layout: .quadrants,
|
||||
configurations: [
|
||||
TapZoneConfiguration(position: .topLeft, action: .seekBackward(seconds: 10)),
|
||||
TapZoneConfiguration(position: .topRight, action: .seekForward(seconds: 10)),
|
||||
TapZoneConfiguration(position: .bottomLeft, action: .playPrevious),
|
||||
TapZoneConfiguration(position: .bottomRight, action: .playNext)
|
||||
],
|
||||
onZoneTapped: { _ in }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user