EARLY ACCESS
Shared Project
Open in bitrigDownload Code
import SwiftUI
import AVFoundation
@main
struct ChapterMarkerApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
@State private var isRecording = false
@State private var startTime = Date()
@State private var elapsedTime: TimeInterval = 0
@State private var timer: Timer?
@State private var markers: [(time: TimeInterval, name: String)] = []
@State private var audioEngine = AVAudioEngine()
@State private var showingExport = false
@State private var exportText = ""
var formattedTime: String {
let hours = Int(elapsedTime) / 3600
let minutes = (Int(elapsedTime) % 3600) / 60
let seconds = Int(elapsedTime) % 60
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 30) {
// Timer Display
VStack(spacing: 10) {
Text(formattedTime)
.font(.system(size: 48, weight: .bold, design: .monospaced))
.foregroundColor(isRecording ? .red : .primary)
Text(isRecording ? "Recording" : "Ready")
.font(.headline)
.foregroundColor(.secondary)
}
.padding()
// Control Buttons
VStack(spacing: 15) {
Button(action: toggleRecording) {
HStack {
Image(systemName: isRecording ? "stop.fill" : "record.circle")
.font(.title2)
Text(isRecording ? "Stop Recording" : "Start Recording")
.font(.headline)
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(isRecording ? Color.red : Color.blue)
.cornerRadius(12)
}
Button(action: addMarker) {
HStack {
Image(systemName: "bookmark.fill")
.font(.title2)
Text("Add Chapter Marker")
.font(.headline)
}
.foregroundColor(isRecording ? .white : .secondary)
.frame(maxWidth: .infinity)
.padding()
.background(isRecording ? Color.green : Color.gray.opacity(0.3))
.cornerRadius(12)
}
.disabled(!isRecording)
if !markers.isEmpty && !isRecording {
Button(action: exportMarkers) {
HStack {
Image(systemName: "square.and.arrow.up")
.font(.title2)
Text("Export Markers")
.font(.headline)
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.orange)
.cornerRadius(12)
}
}
}
.padding(.horizontal)
// Markers List
if !markers.isEmpty {
VStack(alignment: .leading, spacing: 0) {
Text("Chapter Markers")
.font(.headline)
.padding(.horizontal)
.padding(.bottom, 10)
LazyVStack(spacing: 1) {
ForEach(Array(markers.enumerated()), id: \.offset) { index, marker in
HStack {
Text(marker.name)
.font(.body)
Spacer()
Text(timeString(from: marker.time))
.font(.system(.body, design: .monospaced))
.foregroundColor(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 12)
.background(Color(.systemGray6))
}
}
.cornerRadius(10)
}
.padding(.horizontal)
}
Spacer(minLength: 100)
}
}
.navigationTitle("Chapter Markers")
.sheet(isPresented: $showingExport) {
NavigationStack {
ScrollView {
Text(exportText)
.font(.system(.body, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.navigationTitle("Export")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Done") {
showingExport = false
}
}
ToolbarItem(placement: .navigationBarTrailing) {
ShareLink(item: exportText) {
Image(systemName: "square.and.arrow.up")
}
}
}
}
}
}
.onAppear {
setupAudio()
}
.onReceive(Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()) { _ in
if isRecording {
elapsedTime = Date().timeIntervalSince(startTime)
}
}
}
private func setupAudio() {
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("Audio setup failed")
}
}
private func toggleRecording() {
if isRecording {
// Stop recording
isRecording = false
timer?.invalidate()
timer = nil
} else {
// Start recording
playStartSound()
isRecording = true
startTime = Date()
elapsedTime = 0
markers.removeAll()
}
}
private func playStartSound() {
guard let url = Bundle.main.url(forResource: "beep", withExtension: "wav") ??
createBeepFile() else { return }
do {
let audioFile = try AVAudioFile(forReading: url)
let audioPlayerNode = AVAudioPlayerNode()
audioEngine.attach(audioPlayerNode)
audioEngine.connect(audioPlayerNode, to: audioEngine.mainMixerNode, format: audioFile.processingFormat)
try audioEngine.start()
audioPlayerNode.scheduleFile(audioFile, at: nil, completionHandler: nil)
audioPlayerNode.play()
} catch {
// Fallback to system sound
AudioServicesPlaySystemSound(1103)
}
}
private func createBeepFile() -> URL? {
// Create a simple beep tone programmatically if no audio file exists
return nil
}
private func addMarker() {
guard isRecording else { return }
let markerNumber = markers.count + 1
let markerName = "Marker \(markerNumber)"
markers.append((time: elapsedTime, name: markerName))
}
private func exportMarkers() {
var exportLines: [String] = []
for marker in markers {
let timeString = self.timeString(from: marker.time)
exportLines.append("\(timeString) \(marker.name)")
}
exportText = exportLines.joined(separator: "\n")
showingExport = true
}
private func timeString(from interval: TimeInterval) -> String {
let hours = Int(interval) / 3600
let minutes = (Int(interval) % 3600) / 60
let seconds = Int(interval) % 60
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
}
}