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)
|
||||
}
|
||||
}
|
||||
}
|
||||
61
Sources/BearWaveCore/Views/GroupRowView.swift
Normal file
61
Sources/BearWaveCore/Views/GroupRowView.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GroupRowView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
let group: StationGroup
|
||||
@State private var showingRenameSheet = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Label(group.name, systemImage: "folder.fill")
|
||||
Spacer()
|
||||
Text(group.stationIDs.count, format: .number)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.tag(group.id)
|
||||
.contextMenu {
|
||||
Button("Rename") {
|
||||
showingRenameSheet = true
|
||||
}
|
||||
Button("Delete") {
|
||||
model.library.deleteGroup(group)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingRenameSheet) {
|
||||
RenameGroupView(group: group)
|
||||
.environment(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RenameGroupView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let group: StationGroup
|
||||
@State private var name = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Rename folder")
|
||||
.font(.title2.bold())
|
||||
|
||||
TextField("Folder name", text: $name)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onAppear { name = group.name }
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { dismiss() }
|
||||
Button("Rename") {
|
||||
model.library.renameGroup(group, to: name)
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 340)
|
||||
}
|
||||
}
|
||||
36
Sources/BearWaveCore/Views/ManualStationView.swift
Normal file
36
Sources/BearWaveCore/Views/ManualStationView.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ManualStationView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
@State private var url = ""
|
||||
@State private var country = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Add station manually")
|
||||
.font(.title2.bold())
|
||||
|
||||
TextField("Name", text: $name)
|
||||
TextField("Stream URL (http/https)", text: $url)
|
||||
TextField("Country (optional)", text: $country)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
Button("Add") {
|
||||
model.library.addManualStation(name: name, url: url, country: country)
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || URL(string: url) == nil)
|
||||
}
|
||||
}
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.padding(24)
|
||||
.frame(width: 430)
|
||||
}
|
||||
}
|
||||
51
Sources/BearWaveCore/Views/MenuBarContentView.swift
Normal file
51
Sources/BearWaveCore/Views/MenuBarContentView.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct MenuBarContentView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
Button {
|
||||
openWindow(id: "main")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
} label: {
|
||||
Label("Show BearWave", systemImage: "macwindow")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if model.settings.showMenuBarExtraDetails {
|
||||
Text(model.playback.currentStation?.name ?? "BearWave")
|
||||
Text(model.playback.state.title)
|
||||
}
|
||||
|
||||
Button(model.playback.isPlaying ? "Pause" : "Play") {
|
||||
model.playback.togglePlayPause()
|
||||
}
|
||||
.disabled(model.playback.currentStation == nil)
|
||||
|
||||
Button("Resume") {
|
||||
model.resumeLastStation()
|
||||
}
|
||||
.disabled(!model.library.canResume)
|
||||
|
||||
if !model.library.favorites.isEmpty {
|
||||
Divider()
|
||||
ForEach(model.library.favorites.prefix(6)) { station in
|
||||
Button(station.name) {
|
||||
model.play(station)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Quit BearWave") {
|
||||
model.quit()
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
.keyboardShortcut("q")
|
||||
}
|
||||
}
|
||||
109
Sources/BearWaveCore/Views/MiniPlayerView.swift
Normal file
109
Sources/BearWaveCore/Views/MiniPlayerView.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MiniPlayerView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Binding var isMiniPlayer: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(nsImage: model.playback.currentArtwork)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 64, height: 64)
|
||||
.background(.quaternary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(model.playback.currentStation?.name ?? String(localized: "No station selected"))
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Text(miniPlayerText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(playerDetailColor)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
model.playback.togglePlayPause()
|
||||
} label: {
|
||||
Image(systemName: model.playback.isPlaying ? "pause.fill" : "play.fill")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(model.playback.currentStation == nil)
|
||||
|
||||
Button {
|
||||
model.playback.stop()
|
||||
} label: {
|
||||
Image(systemName: "stop.fill")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "speaker.wave.1")
|
||||
.font(.caption)
|
||||
Slider(value: Binding(
|
||||
get: { model.playback.volume },
|
||||
set: { model.setVolume($0) }
|
||||
), in: 0...1)
|
||||
.frame(width: 80)
|
||||
Image(systemName: "speaker.wave.3")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button {
|
||||
isMiniPlayer = false
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.help("Show full window")
|
||||
}
|
||||
.padding(12)
|
||||
.frame(height: 110)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var miniPlayerText: String {
|
||||
if let detail = model.playback.state.detail {
|
||||
return "\(model.playback.state.title): \(detail)"
|
||||
}
|
||||
switch model.playback.state {
|
||||
case .connecting, .buffering, .failed:
|
||||
return model.playback.state.title
|
||||
default:
|
||||
break
|
||||
}
|
||||
if !model.playback.currentTrackTitle.isEmpty && !model.playback.currentTrackArtist.isEmpty {
|
||||
return "\(model.playback.currentTrackArtist) - \(model.playback.currentTrackTitle)"
|
||||
}
|
||||
if !model.playback.currentTrackTitle.isEmpty {
|
||||
return model.playback.currentTrackTitle
|
||||
}
|
||||
if !model.playback.currentTrackArtist.isEmpty {
|
||||
return model.playback.currentTrackArtist
|
||||
}
|
||||
if let station = model.playback.currentStation, !station.tags.isEmpty {
|
||||
return station.tags
|
||||
}
|
||||
return String(localized: "Live radio")
|
||||
}
|
||||
|
||||
private var playerDetailColor: Color {
|
||||
if case .failed = model.playback.state {
|
||||
return .red
|
||||
}
|
||||
if case .connecting = model.playback.state {
|
||||
return .secondary
|
||||
}
|
||||
if case .buffering = model.playback.state {
|
||||
return .orange
|
||||
}
|
||||
return .secondary
|
||||
}
|
||||
}
|
||||
32
Sources/BearWaveCore/Views/NewGroupView.swift
Normal file
32
Sources/BearWaveCore/Views/NewGroupView.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NewGroupView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Create new folder")
|
||||
.font(.title2.bold())
|
||||
|
||||
TextField("Folder name", text: $name)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
Button("Create") {
|
||||
model.library.createGroup(name: name)
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 340)
|
||||
}
|
||||
}
|
||||
99
Sources/BearWaveCore/Views/PlayerBarView.swift
Normal file
99
Sources/BearWaveCore/Views/PlayerBarView.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerBarView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(nsImage: model.playback.currentArtwork)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 46, height: 46)
|
||||
.background(.quaternary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(model.playback.currentStation?.name ?? model.library.selectedStation?.name ?? String(localized: "No station selected"))
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Text(nowPlayingText)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(playerDetailColor)
|
||||
.lineLimit(1)
|
||||
.help(nowPlayingText)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
radioControls
|
||||
|
||||
HStack {
|
||||
Image(systemName: "speaker.wave.1")
|
||||
Slider(value: Binding(
|
||||
get: { model.playback.volume },
|
||||
set: { model.setVolume($0) }
|
||||
), in: 0...1)
|
||||
.frame(width: 150)
|
||||
Image(systemName: "speaker.wave.3")
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var radioControls: some View {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
model.playback.togglePlayPause()
|
||||
} label: {
|
||||
Label(model.playback.isPlaying ? "Pause" : "Play", systemImage: model.playback.isPlaying ? "pause.fill" : "play.fill")
|
||||
}
|
||||
.disabled(model.playback.currentStation == nil)
|
||||
|
||||
Button {
|
||||
model.playback.stop()
|
||||
} label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var nowPlayingText: String {
|
||||
if let detail = model.playback.state.detail {
|
||||
return "\(model.playback.state.title): \(detail)"
|
||||
}
|
||||
switch model.playback.state {
|
||||
case .connecting, .buffering, .failed:
|
||||
return model.playback.state.title
|
||||
default:
|
||||
break
|
||||
}
|
||||
if !model.playback.currentTrackTitle.isEmpty && !model.playback.currentTrackArtist.isEmpty {
|
||||
return "\(model.playback.currentTrackArtist) - \(model.playback.currentTrackTitle)"
|
||||
}
|
||||
if !model.playback.currentTrackTitle.isEmpty {
|
||||
return model.playback.currentTrackTitle
|
||||
}
|
||||
if !model.playback.currentTrackArtist.isEmpty {
|
||||
return model.playback.currentTrackArtist
|
||||
}
|
||||
if let station = model.playback.currentStation, !station.tags.isEmpty {
|
||||
return station.tags
|
||||
}
|
||||
return String(localized: "Live radio")
|
||||
}
|
||||
|
||||
private var playerDetailColor: Color {
|
||||
if case .failed = model.playback.state {
|
||||
return .red
|
||||
}
|
||||
if case .connecting = model.playback.state {
|
||||
return .secondary
|
||||
}
|
||||
if case .buffering = model.playback.state {
|
||||
return .orange
|
||||
}
|
||||
return .secondary
|
||||
}
|
||||
}
|
||||
38
Sources/BearWaveCore/Views/SettingsView.swift
Normal file
38
Sources/BearWaveCore/Views/SettingsView.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct SettingsView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
Form {
|
||||
Section("Radio Browser") {
|
||||
TextField("Base URL", text: Binding(
|
||||
get: { model.settings.radioBrowserBaseURL },
|
||||
set: {
|
||||
model.settings.radioBrowserBaseURL = $0
|
||||
model.applySettings()
|
||||
}
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
Section("Playback") {
|
||||
Toggle("Resume last station on launch", isOn: Binding(
|
||||
get: { model.settings.resumeOnLaunch },
|
||||
set: { model.settings.resumeOnLaunch = $0 }
|
||||
))
|
||||
}
|
||||
|
||||
Section("Menu Bar") {
|
||||
Toggle("Show playback details in menu bar", isOn: Binding(
|
||||
get: { model.settings.showMenuBarExtraDetails },
|
||||
set: { model.settings.showMenuBarExtraDetails = $0 }
|
||||
))
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
79
Sources/BearWaveCore/Views/SidebarView.swift
Normal file
79
Sources/BearWaveCore/Views/SidebarView.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SidebarView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var showingNewFolderSheet = false
|
||||
@State private var selection: String = ""
|
||||
|
||||
var body: some View {
|
||||
List(selection: $selection) {
|
||||
Section("Stations") {
|
||||
pageRow(.top)
|
||||
pageRow(.germany)
|
||||
pageRow(.netherlands)
|
||||
pageRow(.world)
|
||||
pageRow(.search, badge: model.library.selectedPage == .search ? model.library.stations.count : nil)
|
||||
}
|
||||
Section("Library") {
|
||||
pageRow(.favorites, badge: model.library.favorites.count)
|
||||
pageRow(.recent, badge: model.library.recentStations.count)
|
||||
pageRow(.manual, badge: model.library.manualStations.count)
|
||||
}
|
||||
if !model.library.groups.isEmpty {
|
||||
Section("Folders") {
|
||||
ForEach(model.library.groups.sorted { $0.sortOrder < $1.sortOrder }) { group in
|
||||
GroupRowView(group: group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
updateSelection()
|
||||
}
|
||||
.onChange(of: model.library.selectedPage) { _, _ in updateSelection() }
|
||||
.onChange(of: model.library.selectedGroupID) { _, _ in updateSelection() }
|
||||
.onChange(of: selection) { _, newValue in
|
||||
handleSelectionChange(newValue)
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 280)
|
||||
.contextMenu {
|
||||
Button("New Folder") {
|
||||
showingNewFolderSheet = true
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingNewFolderSheet) {
|
||||
NewGroupView()
|
||||
.environment(model)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSelection() {
|
||||
if model.library.selectedPage == .group {
|
||||
selection = model.library.selectedGroupID ?? ""
|
||||
} else {
|
||||
selection = model.library.selectedPage.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSelectionChange(_ value: String) {
|
||||
if let group = model.library.groups.first(where: { $0.id == value }) {
|
||||
model.library.selectGroup(group)
|
||||
} else if let page = StationPage(rawValue: value) {
|
||||
Task { await model.library.load(page) }
|
||||
}
|
||||
}
|
||||
|
||||
private func pageRow(_ page: StationPage, badge: Int? = nil) -> some View {
|
||||
HStack {
|
||||
Label(page.title, systemImage: page.systemImage)
|
||||
Spacer()
|
||||
if let badge, badge > 0 {
|
||||
Text(badge, format: .number)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.tag(page.rawValue)
|
||||
}
|
||||
}
|
||||
177
Sources/BearWaveCore/Views/StationDetailView.swift
Normal file
177
Sources/BearWaveCore/Views/StationDetailView.swift
Normal file
@@ -0,0 +1,177 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct StationDetailView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let station = model.library.selectedStation {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header(station)
|
||||
actions(station)
|
||||
if model.playback.currentStation?.id == station.id {
|
||||
nowPlayingSection
|
||||
}
|
||||
technicalSection(station)
|
||||
linksSection(station)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView("No station selected", systemImage: "radio")
|
||||
}
|
||||
}
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
|
||||
private func header(_ station: RadioStation) -> some View {
|
||||
let artworkImage: NSImage
|
||||
if model.playback.currentStation?.id == station.id {
|
||||
artworkImage = model.playback.currentArtwork
|
||||
} else {
|
||||
artworkImage = model.artwork.image(for: station) ?? NSImage()
|
||||
}
|
||||
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
Image(nsImage: artworkImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 72, height: 72)
|
||||
.background(.quaternary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(station.name)
|
||||
.font(.title3.weight(.semibold))
|
||||
.lineLimit(2)
|
||||
if !station.country.isEmpty {
|
||||
Text(station.country)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func actions(_ station: RadioStation) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
model.play(station)
|
||||
} label: {
|
||||
Label("Play", systemImage: "play.fill")
|
||||
}
|
||||
|
||||
Button {
|
||||
model.library.toggleFavorite(station)
|
||||
} label: {
|
||||
Label(model.library.isFavorite(station) ? "Remove Favorite" : "Add Favorite", systemImage: model.library.isFavorite(station) ? "star.fill" : "star")
|
||||
}
|
||||
|
||||
if !model.library.groups.isEmpty {
|
||||
Menu {
|
||||
ForEach(model.library.groups) { group in
|
||||
Button {
|
||||
if model.library.isStationInGroup(station, group: group) {
|
||||
model.library.removeStationFromGroup(station, group: group)
|
||||
} else {
|
||||
model.library.addStationToGroup(station, group: group)
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
model.library.isStationInGroup(station, group: group) ? "Remove from \(group.name)" : "Add to \(group.name)",
|
||||
systemImage: model.library.isStationInGroup(station, group: group) ? "checkmark" : "plus"
|
||||
)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Folders", systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
private var nowPlayingSection: some View {
|
||||
DetailSection(title: "Now Playing") {
|
||||
LabeledContent("Status", value: model.playback.state.title)
|
||||
if case let .failed(error) = model.playback.state {
|
||||
Text(error.detail)
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
if !model.playback.currentTrackArtist.isEmpty {
|
||||
LabeledContent("Artist", value: model.playback.currentTrackArtist)
|
||||
}
|
||||
if !model.playback.currentTrackTitle.isEmpty {
|
||||
LabeledContent("Title", value: model.playback.currentTrackTitle)
|
||||
}
|
||||
if model.playback.currentTrackArtist.isEmpty && model.playback.currentTrackTitle.isEmpty, !model.playback.state.isPlaying, model.playback.state != .stopped {
|
||||
Text("No stream metadata yet")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func technicalSection(_ station: RadioStation) -> some View {
|
||||
DetailSection(title: "Details") {
|
||||
if !station.codec.isEmpty {
|
||||
LabeledContent("Codec", value: station.codec.uppercased())
|
||||
}
|
||||
if station.bitrate > 0 {
|
||||
LabeledContent("Bitrate", value: "\(station.bitrate) kbps")
|
||||
}
|
||||
if station.votes > 0 {
|
||||
LabeledContent("Votes", value: station.votes.formatted())
|
||||
}
|
||||
if !station.tags.isEmpty {
|
||||
LabeledContent("Tags") {
|
||||
Text(station.tags)
|
||||
.lineLimit(4)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func linksSection(_ station: RadioStation) -> some View {
|
||||
DetailSection(title: "Links") {
|
||||
if let homepage = URL(string: station.homepage), !station.homepage.isEmpty {
|
||||
Button {
|
||||
openURL(homepage)
|
||||
} label: {
|
||||
Label("Open Homepage", systemImage: "safari")
|
||||
}
|
||||
}
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(station.playbackURLString, forType: .string)
|
||||
} label: {
|
||||
Label("Copy Stream URL", systemImage: "doc.on.doc")
|
||||
}
|
||||
.disabled(station.playbackURLString.isEmpty)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DetailSection<Content: View>: View {
|
||||
let title: LocalizedStringKey
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
content
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
140
Sources/BearWaveCore/Views/StationListView.swift
Normal file
140
Sources/BearWaveCore/Views/StationListView.swift
Normal file
@@ -0,0 +1,140 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StationListView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let error = model.library.lastError {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(.red.opacity(0.08))
|
||||
}
|
||||
|
||||
if model.library.visibleStations.isEmpty && !model.library.isLoading {
|
||||
ContentUnavailableView(
|
||||
"No stations loaded yet",
|
||||
systemImage: "radio",
|
||||
description: Text("Load a station page or search worldwide.")
|
||||
)
|
||||
} else {
|
||||
List(selection: Binding(
|
||||
get: { model.library.selectedStationID },
|
||||
set: { model.library.selectedStationID = $0 }
|
||||
)) {
|
||||
ForEach(model.library.visibleStations) { station in
|
||||
StationRowView(station: station)
|
||||
.tag(station.id)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
model.library.select(station)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(model.library.isFavorite(station) ? "Remove Favorite" : "Add Favorite") {
|
||||
model.library.toggleFavorite(station)
|
||||
}
|
||||
if !model.library.groups.isEmpty {
|
||||
Menu("Add to Folder") {
|
||||
ForEach(model.library.groups) { group in
|
||||
Button(group.name) {
|
||||
model.library.addStationToGroup(station, group: group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Play") {
|
||||
model.play(station)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StationRowView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
let station: RadioStation
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(nsImage: model.artwork.image(for: station) ?? NSImage())
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 34, height: 34)
|
||||
.background(.quaternary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
Image(systemName: station.isOnline ? "dot.radiowaves.left.and.right" : "exclamationmark.triangle")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(station.isOnline ? Color.secondary : Color.orange)
|
||||
.padding(2)
|
||||
.background(.regularMaterial, in: Circle())
|
||||
.offset(x: 4, y: 4)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(station.name)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 8) {
|
||||
if isCurrentStation {
|
||||
Text(model.playback.state.title)
|
||||
.foregroundStyle(statusColor)
|
||||
}
|
||||
if !station.country.isEmpty {
|
||||
Text(station.country)
|
||||
}
|
||||
if !station.codec.isEmpty {
|
||||
Text(station.codec.uppercased())
|
||||
}
|
||||
if station.bitrate > 0 {
|
||||
Text("\(station.bitrate) kbps")
|
||||
}
|
||||
if station.votes > 0 {
|
||||
Text("\(station.votes) votes")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
model.library.toggleFavorite(station)
|
||||
} label: {
|
||||
Image(systemName: model.library.isFavorite(station) ? "star.fill" : "star")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(model.library.isFavorite(station) ? .yellow : .secondary)
|
||||
|
||||
Button {
|
||||
model.play(station)
|
||||
} label: {
|
||||
Image(systemName: "play.fill")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
|
||||
private var isCurrentStation: Bool {
|
||||
model.playback.currentStation?.id == station.id
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
if case .failed = model.playback.state {
|
||||
return .red
|
||||
}
|
||||
if case .buffering = model.playback.state {
|
||||
return .orange
|
||||
}
|
||||
return .secondary
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user