Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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))
}
}
}

View File

@@ -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 }
)
}
}
}