Skip to content

Commit b27024c

Browse files
committed
add tvOS App
1 parent 750d0f5 commit b27024c

File tree

32 files changed

+679
-16
lines changed

32 files changed

+679
-16
lines changed

Shared/ContentView.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77

88
import SwiftUI
9+
import AVKit
910

1011
struct ContentView: View {
1112

@@ -35,8 +36,7 @@ struct ContentView: View {
3536
isTextFieldFocused = false
3637
}
3738
}
38-
39-
#if !os(watchOS)
39+
#if os(iOS) || os(macOS)
4040
Divider()
4141
bottomView(image: "profile", proxy: proxy)
4242
Spacer()
@@ -66,7 +66,7 @@ struct ContentView: View {
6666
}
6767

6868
TextField("Send message", text: $vm.inputMessage, axis: .vertical)
69-
#if !os(watchOS)
69+
#if os(iOS) || os(macOS)
7070
.textFieldStyle(.roundedBorder)
7171
#endif
7272
.focused($isTextFieldFocused)
@@ -92,7 +92,6 @@ struct ContentView: View {
9292
.foregroundColor(.accentColor)
9393
#endif
9494
.disabled(vm.inputMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
95-
9695
}
9796
}
9897
.padding(.horizontal, 16)

Shared/MessageRowView.swift

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ struct MessageRowView: View {
1313
let message: MessageRow
1414
let retryCallback: (MessageRow) -> Void
1515

16+
var imageSize: CGSize {
17+
#if os(iOS) || os(macOS)
18+
CGSize(width: 25, height: 25)
19+
#elseif os(watchOS)
20+
CGSize(width: 20, height: 20)
21+
#else
22+
CGSize(width: 80, height: 80)
23+
#endif
24+
}
25+
1626
var body: some View {
1727
VStack(spacing: 0) {
1828
messageRow(text: message.sendText, image: message.sendImage, bgColor: colorScheme == .light ? .white : Color(red: 52/255, green: 53/255, blue: 65/255, opacity: 0.5))
@@ -34,12 +44,15 @@ struct MessageRowView: View {
3444
.padding(16)
3545
.frame(maxWidth: .infinity, alignment: .leading)
3646
.background(bgColor)
37-
3847
#else
3948
HStack(alignment: .top, spacing: 24) {
4049
messageRowContent(text: text, image: image, responseError: responseError, showDotLoading: showDotLoading)
4150
}
51+
#if os(tvOS)
52+
.padding(32)
53+
#else
4254
.padding(16)
55+
#endif
4356
.frame(maxWidth: .infinity, alignment: .leading)
4457
.background(bgColor)
4558
#endif
@@ -51,24 +64,28 @@ struct MessageRowView: View {
5164
AsyncImage(url: url) { image in
5265
image
5366
.resizable()
54-
.frame(width: 25, height: 25)
67+
.frame(width: imageSize.width, height: imageSize.height)
5568
} placeholder: {
5669
ProgressView()
5770
}
5871

5972
} else {
6073
Image(image)
6174
.resizable()
62-
.frame(width: 25, height: 25)
75+
.frame(width: imageSize.width, height: imageSize.height)
6376
}
6477

6578
VStack(alignment: .leading) {
6679
if !text.isEmpty {
80+
#if os(tvOS)
81+
responseTextView(text: text)
82+
#else
6783
Text(text)
6884
.multilineTextAlignment(.leading)
69-
#if !os(watchOS)
85+
#if os(iOS) || os(macOS)
7086
.textSelection(.enabled)
7187
#endif
88+
#endif
7289
}
7390

7491
if let error = responseError {
@@ -84,13 +101,52 @@ struct MessageRowView: View {
84101
}
85102

86103
if showDotLoading {
104+
#if os(tvOS)
105+
ProgressView()
106+
.progressViewStyle(.circular)
107+
.padding()
108+
#else
87109
DotLoadingView()
88110
.frame(width: 60, height: 30)
111+
#endif
112+
89113
}
90114
}
91-
115+
}
116+
117+
#if os(tvOS)
118+
private func rowsFor(text: String) -> [String] {
119+
var rows = [String]()
120+
let maxLinesPerRow = 8
121+
var currentRowText = ""
122+
var currentLineSum = 0
92123

124+
for char in text {
125+
currentRowText += String(char)
126+
if char == "\n" {
127+
currentLineSum += 1
128+
}
129+
130+
if currentLineSum >= maxLinesPerRow {
131+
rows.append(currentRowText)
132+
currentLineSum = 0
133+
currentRowText = ""
134+
}
135+
}
136+
137+
rows.append(currentRowText)
138+
return rows
139+
}
140+
141+
142+
func responseTextView(text: String) -> some View {
143+
ForEach(rowsFor(text: text), id: \.self) { text in
144+
Text(text)
145+
.focusable()
146+
.multilineTextAlignment(.leading)
147+
}
93148
}
149+
#endif
94150

95151
}
96152

Shared/ViewModel.swift

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,23 @@
77

88
import Foundation
99
import SwiftUI
10+
import AVKit
1011

1112
class ViewModel: ObservableObject {
1213

1314
@Published var isInteractingWithChatGPT = false
1415
@Published var messages: [MessageRow] = []
1516
@Published var inputMessage: String = ""
1617

18+
private var synthesizer: AVSpeechSynthesizer?
19+
1720
private let api: ChatGPTAPI
1821

19-
init(api: ChatGPTAPI) {
22+
init(api: ChatGPTAPI, enableSpeech: Bool = false) {
2023
self.api = api
24+
if enableSpeech {
25+
synthesizer = .init()
26+
}
2127
}
2228

2329
@MainActor
@@ -29,6 +35,7 @@ class ViewModel: ObservableObject {
2935

3036
@MainActor
3137
func clearMessages() {
38+
stopSpeaking()
3239
withAnimation { [weak self] in
3340
self?.messages = []
3441
}
@@ -71,6 +78,25 @@ class ViewModel: ObservableObject {
7178
messageRow.isInteractingWithChatGPT = false
7279
self.messages[self.messages.count - 1] = messageRow
7380
isInteractingWithChatGPT = false
81+
speakLastResponse()
82+
83+
}
84+
85+
func speakLastResponse() {
86+
guard let synthesizer, let responseText = self.messages.last?.responseText, !responseText.isEmpty else {
87+
return
88+
}
89+
stopSpeaking()
90+
let utterance = AVSpeechUtterance(string: responseText)
91+
utterance.voice = .init(language: "en-US")
92+
utterance.rate = 0.5
93+
utterance.pitchMultiplier = 0.8
94+
utterance.postUtteranceDelay = 0.2
95+
synthesizer.speak(utterance )
96+
}
97+
98+
func stopSpeaking() {
99+
synthesizer?.stopSpeaking(at: .immediate)
74100
}
75101

76102
}

0 commit comments

Comments
 (0)