Create BearWave macOS radio-first app
This commit is contained in:
206
Sources/BearWaveCore/Views/ContentView.swift
Normal file
206
Sources/BearWaveCore/Views/ContentView.swift
Normal file
@@ -0,0 +1,206 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct ContentView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@State private var searchText = ""
|
||||
@State private var lastSubmittedSearch = ""
|
||||
@State private var pendingSearchTask: Task<Void, Never>?
|
||||
@State private var showingManualSheet = false
|
||||
@State private var showingNewFolderSheet = false
|
||||
@Binding var isMiniPlayer: Bool
|
||||
|
||||
public init(isMiniPlayer: Binding<Bool> = .constant(false)) {
|
||||
_isMiniPlayer = isMiniPlayer
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Group {
|
||||
if isMiniPlayer {
|
||||
MiniPlayerView(isMiniPlayer: $isMiniPlayer)
|
||||
} else {
|
||||
fullView
|
||||
}
|
||||
}
|
||||
.navigationTitle("BearWave")
|
||||
.onChange(of: searchText) { _, newValue in
|
||||
scheduleSearch(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
private var fullView: some View {
|
||||
NavigationSplitView {
|
||||
SidebarView()
|
||||
.frame(minWidth: 220, idealWidth: 220, maxWidth: 280)
|
||||
} detail: {
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider()
|
||||
HSplitView {
|
||||
StationListView()
|
||||
.frame(minWidth: 520)
|
||||
StationDetailView()
|
||||
.frame(minWidth: 280, idealWidth: 320, maxWidth: 380)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
PlayerBarView()
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.automatic)
|
||||
.toolbar {
|
||||
ToolbarItemGroup {
|
||||
Button {
|
||||
showingManualSheet = true
|
||||
} label: {
|
||||
Label("Add Station", systemImage: "plus")
|
||||
}
|
||||
Button {
|
||||
model.resumeLastStation()
|
||||
} label: {
|
||||
Label("Resume", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(!model.library.canResume)
|
||||
Button {
|
||||
showingNewFolderSheet = true
|
||||
} label: {
|
||||
Label("New Folder", systemImage: "folder.badge.plus")
|
||||
}
|
||||
Button {
|
||||
isMiniPlayer = true
|
||||
} label: {
|
||||
Label("Mini Player", systemImage: "arrow.down.left.and.arrow.up.right")
|
||||
}
|
||||
.keyboardShortcut("m", modifiers: .command)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingManualSheet) {
|
||||
ManualStationView()
|
||||
.environment(model)
|
||||
}
|
||||
.sheet(isPresented: $showingNewFolderSheet) {
|
||||
NewGroupView()
|
||||
.environment(model)
|
||||
}
|
||||
}
|
||||
|
||||
private var toolbar: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
HStack(spacing: 9) {
|
||||
Image(nsImage: BearWaveAssets.image(named: "bearwave_line") ?? NSImage())
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 112, height: 32)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("BearWave")
|
||||
.font(.headline.weight(.semibold))
|
||||
Text("Internet Radio")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(width: 220, alignment: .leading)
|
||||
|
||||
Divider()
|
||||
.frame(height: 32)
|
||||
|
||||
TextField("Search stations, genre, country", text: Binding(
|
||||
get: { searchText },
|
||||
set: { newValue in
|
||||
searchText = newValue
|
||||
}
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit {
|
||||
Task { await submitSearch(searchText) }
|
||||
}
|
||||
.frame(minWidth: 280, idealWidth: 420, maxWidth: .infinity)
|
||||
|
||||
Button {
|
||||
Task { await submitSearch(searchText) }
|
||||
} label: {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
}
|
||||
.disabled(searchText.trimmingCharacters(in: .whitespacesAndNewlines).count < 2)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
|
||||
Button {
|
||||
searchText = ""
|
||||
lastSubmittedSearch = ""
|
||||
pendingSearchTask?.cancel()
|
||||
model.library.filterQuery = ""
|
||||
Task { await model.library.loadSelectedPage() }
|
||||
} label: {
|
||||
Label("Clear", systemImage: "xmark.circle")
|
||||
}
|
||||
.disabled(searchText.isEmpty)
|
||||
|
||||
if model.library.isLoading {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Quick")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
ForEach(["rock", "news", "jazz"], id: \.self) { tag in
|
||||
Button(tag.capitalized) {
|
||||
Task { await model.library.loadTag(tag) }
|
||||
}
|
||||
}
|
||||
Divider().frame(height: 20)
|
||||
ForEach(["US", "GB", "FR"], id: \.self) { country in
|
||||
Button(country) {
|
||||
Task { await model.library.loadCountry(country) }
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
|
||||
Text("Sort")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("", selection: Binding(
|
||||
get: { model.library.sortMode },
|
||||
set: { model.library.sortMode = $0 }
|
||||
)) {
|
||||
ForEach(StationSortMode.allCases) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 230)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
|
||||
private func submitSearch(_ rawQuery: String) async {
|
||||
let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard query.count >= 2 else { return }
|
||||
if query == lastSubmittedSearch, !model.library.visibleStations.isEmpty {
|
||||
return
|
||||
}
|
||||
lastSubmittedSearch = query
|
||||
await model.library.search(query)
|
||||
}
|
||||
|
||||
private func scheduleSearch(_ rawQuery: String) {
|
||||
pendingSearchTask?.cancel()
|
||||
let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard query.count >= 3 else { return }
|
||||
|
||||
pendingSearchTask = Task {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
guard !Task.isCancelled else { return }
|
||||
await submitSearch(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user