HuggingSnap/Views/ControlView.swift (269 lines of code) (raw):
//
// ControlView.swift
// HuggingSnap
//
// Created by Cyril Zakka on 2/18/25.
//
import Foundation
import SwiftUI
import PhotosUI
struct ControlView: View {
// SORRY THIS IS MESSY. Should clean it up someday when we know what features should be added.
@EnvironmentObject var model: ContentViewModel
// Bindings
@Binding var selectedItem: PhotosPickerItem?
@State private var isProcessing = false
// Main button
@Binding var isCaptured: Bool
@State private var scaleDown: Bool = false
@State private var rotation: Double = 0
@State private var opacity: Double = 0.3
@Environment(VLMEvaluator.self) private var llm
@Binding var loadState: LoadState
// Alt buttons
@State private var showInputView: Bool = false
@State private var isAudioMode: Bool = false
@State private var textFieldText = ""
private var gradient: AngularGradient {
AngularGradient(
gradient: Gradient(colors: [
.clear,
.white.opacity(opacity+0.1),
.clear,
.white.opacity(opacity),
.clear,
.white.opacity(opacity+0.1),
]),
center: .center,
startAngle: .degrees(270),
endAngle: .degrees(0)
)
}
var body: some View {
if showInputView {
InputView(isTextMode: $showInputView, isAudioMode: $isAudioMode, textFieldText: $textFieldText) { textPrompt in
llm.customUserInput = textPrompt
textFieldText = ""
Task {
if case .loadedImage(let uIImage) = loadState {
let ciImage = CIImage(image: uIImage)
await llm.generate(image: ciImage ?? CIImage(), videoURL: nil)
}
if case .loadedMovie(let video) = loadState { await llm.generate(image: nil, videoURL: video.url) }
}
}
} else {
VStack(alignment: .center, spacing: 20) {
if isCaptured {
HStack(spacing: 20) {
if case .loadedImage(let uIImage) = loadState {
Button {
// Image description
llm.customUserInput = ""
Task {
let ciImage = CIImage(image: uIImage)
await llm.generate(image: ciImage ?? CIImage(), videoURL: nil)
}
} label: {
Label("Describe", systemImage: "text.quote")
.foregroundStyle(.white)
.fontWeight(.semibold)
.font(.footnote)
.padding(.vertical, 7)
.padding(.horizontal, 12)
.background {
Capsule()
.fill(.ultraThickMaterial)
}
}
.accessibilityLabel(Text("Press this button to have HuggingSnap describe the current image"))
.transition(.blurReplace.combined(with: .scale))
}
if case .loadedMovie(let video) = loadState {
Button {
llm.customUserInput = ""
Task {
await llm.generate(image: nil, videoURL: video.url)
}
} label: {
Label("Summarize", systemImage: "text.append")
.foregroundStyle(.white)
.fontWeight(.semibold)
.font(.footnote)
.padding(.vertical, 7)
.padding(.horizontal, 12)
.background {
Capsule()
.fill(.ultraThickMaterial)
}
}
.accessibilityLabel(Text("Press this button to have HuggingSnap summarize the current video."))
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
HStack {
if isCaptured {
Button {
isAudioMode = false
showInputView = true
} label: {
ZStack {
Circle()
.fill(.regularMaterial)
.frame(width: 50, height: 50)
Image(systemName: "text.bubble")
.foregroundStyle(.white)
.fontWeight(.bold)
}
}
.accessibilityLabel(Text("Press the button to type a message to HuggingSnap"))
} else {
PhotosPicker(selection: $selectedItem,
matching: .any(of: [.images, .videos])) {
ZStack {
Circle()
.fill(.regularMaterial)
.frame(width: 50, height: 50)
Image(systemName: "photo.fill.on.rectangle.fill")
.foregroundStyle(.white)
.fontWeight(.bold)
}
}
.accessibilityLabel(Text("Press the button to select a photo or video as input to HuggingSnap"))
}
Spacer()
// Capture button which serves as photo capture and video recording
ZStack {
ZStack {
Circle()
.fill(.white)
.frame(width: 20)
TransparentBlurView(removeAllFilters: true)
.blur(radius: 9, opaque: true)
.background(.white.opacity(0.05))
}
.clipShape(.circle)
.frame(width: 60, height: 60)
Circle()
.stroke(gradient, lineWidth: 1)
.frame(width: 80, height: 80)
.rotationEffect(.degrees(rotation))
.onAppear {
withAnimation(.linear(duration: 20)
.repeatForever(autoreverses: false)) {
rotation = 360
}
withAnimation(
.easeInOut(duration: 4)
.repeatForever(autoreverses: true)
) {
opacity = 0.4
}
}
ZStack {
if isCaptured {
Image(systemName: "xmark")
.foregroundStyle(.white)
.fontWeight(.bold)
.imageScale(.large)
.transition(.blurReplace)
.contentShape(.rect)
} else {
RoundedRectangle(cornerRadius: scaleDown ? 24:100)
.fill(scaleDown ? .red:.white)
.frame(width: 70, height: 70)
.transition(.blurReplace)
.scaleEffect(scaleDown ? 0.65 : 1)
}
}
.frame(width: 70, height: 70)
.contentShape(.rect)
.accessibilityLabel(Text(isCaptured ? "Press the button to clear the capture.":"Press the button to snap a picture. Tap and hold to record a video."))
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
if !model.isRecording {
withAnimation {
scaleDown = true
}
#if targetEnvironment(simulator)
#else
if !model.isRecording {
model.toggleRecording()
}
#endif
}
}
.onEnded { _ in
withAnimation(.smooth(duration: 0.1)) {
scaleDown = false
}
if !isCaptured {
#if targetEnvironment(simulator)
#else
if model.isRecording {
model.toggleRecording()
}
#endif
}
}
)
.highPriorityGesture(
TapGesture()
.onEnded {
if !isCaptured {
#if targetEnvironment(simulator)
#else
model.capturePhoto()
#endif
} else {
clearAllInputs()
}
}
)
}
Spacer()
if isCaptured {
Button {
isAudioMode = true
showInputView = true
} label: {
ZStack {
Circle()
.fill(.regularMaterial)
.frame(width: 50, height: 50)
Image(systemName: "mic")
.foregroundStyle(.white)
.fontWeight(.bold)
}
}
.accessibilityLabel(Text("Press the button to dictate a message to HuggingSnap"))
} else {
Button {
#if targetEnvironment(simulator)
#else
model.switchCamera()
#endif
} label: {
ZStack {
Circle()
.fill(.regularMaterial)
.frame(width: 50, height: 50)
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(.white)
.fontWeight(.bold)
}
}
.accessibilityLabel(Text("Press the button to switch between front and back cameras"))
.disabled(isCaptured)
}
}
}
.padding(.horizontal, 40)
.preferredColorScheme(.dark)
.animation(.spring, value: isCaptured)
}
}
func clearAllInputs() {
model.toggleStreaming()
model.movieURL = nil
model.photo = nil
selectedItem = nil
isCaptured = false
loadState = .unknown
llm.output = ""
}
}
#Preview {
ContentView()
.preferredColorScheme(.dark)
}