Create BearWave macOS radio-first app

This commit is contained in:
Sebastian Palencsar
2026-06-25 09:21:42 +02:00
commit b47b591e03
42 changed files with 3814 additions and 0 deletions

View 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)
}
}
}

View 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)
}
}

View 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)
}
}

View 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")
}
}

View 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
}
}

View 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)
}
}

View 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
}
}

View 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)
}
}

View 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)
}
}

View 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)
}
}
}

View 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
}
}