Skip to content

Commit fddd10a

Browse files
authored
Merge pull request #200 from analogcode/dev
Modernize NowPlaying UI and improve app architecture
2 parents 1bab85c + b8f64de commit fddd10a

29 files changed

+1156
-1109
lines changed

.github/workflows/carplay.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,19 @@ on:
99
jobs:
1010
build:
1111
name: Build and Test The SwiftRadio-CarPlay using any available iPhone simulator
12-
runs-on: macos-latest
12+
runs-on: macos-26
1313

1414
steps:
1515
- name: Checkout
16-
uses: actions/checkout@v3
16+
uses: actions/checkout@v4
17+
- name: Select latest Xcode
18+
uses: maxim-lobanov/setup-xcode@v1
19+
with:
20+
xcode-version: latest-stable
21+
- name: Verify toolchain
22+
run: |
23+
xcodebuild -version
24+
swift --version
1725
- name: Set Default Scheme
1826
run: |
1927
scheme_list=$(xcodebuild -list -json | tr -d "\n")

.github/workflows/ios.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,19 @@ on:
99
jobs:
1010
build:
1111
name: Build and Test the SwiftRadio iOS target using any available iPhone simulator
12-
runs-on: macos-latest
12+
runs-on: macos-26
1313

1414
steps:
1515
- name: Checkout
16-
uses: actions/checkout@v3
16+
uses: actions/checkout@v4
17+
- name: Select latest Xcode
18+
uses: maxim-lobanov/setup-xcode@v1
19+
with:
20+
xcode-version: latest-stable
21+
- name: Verify toolchain
22+
run: |
23+
xcodebuild -version
24+
swift --version
1725
- name: Set Default Scheme
1826
run: |
1927
scheme_list=$(xcodebuild -list -json | tr -d "\n")

SwiftRadio.xcodeproj/project.pbxproj

Lines changed: 96 additions & 34 deletions
Large diffs are not rendered by default.

SwiftRadio.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 22 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

SwiftRadio/Cells/StationTableViewCell.swift

Lines changed: 230 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,82 +10,276 @@ import UIKit
1010
import NVActivityIndicatorView
1111

1212
class StationTableViewCell: UITableViewCell {
13-
13+
14+
// MARK: - UI
15+
16+
private let cardBlurView: UIVisualEffectView = {
17+
let blur = UIBlurEffect(style: .systemUltraThinMaterialDark)
18+
let view = UIVisualEffectView(effect: blur)
19+
view.layer.cornerRadius = 14
20+
view.clipsToBounds = true
21+
view.layer.borderWidth = 0.5
22+
view.layer.borderColor = UIColor.white.withAlphaComponent(0.12).cgColor
23+
view.translatesAutoresizingMaskIntoConstraints = false
24+
return view
25+
}()
26+
27+
private let cardView: UIView = {
28+
let view = UIView()
29+
view.backgroundColor = .clear
30+
view.layer.cornerRadius = 14
31+
view.layer.shadowColor = UIColor.black.cgColor
32+
view.layer.shadowOpacity = 0.2
33+
view.layer.shadowOffset = CGSize(width: 0, height: 2)
34+
view.layer.shadowRadius = 8
35+
view.translatesAutoresizingMaskIntoConstraints = false
36+
return view
37+
}()
38+
39+
private let pulseRingView: UIView = {
40+
let view = UIView()
41+
view.layer.cornerRadius = 12
42+
view.layer.borderWidth = 2.5
43+
view.layer.borderColor = UIColor.white.cgColor
44+
view.layer.shadowColor = UIColor.white.cgColor
45+
view.layer.shadowOpacity = 0.5
46+
view.layer.shadowOffset = .zero
47+
view.layer.shadowRadius = 6
48+
view.alpha = 0
49+
view.translatesAutoresizingMaskIntoConstraints = false
50+
return view
51+
}()
52+
1453
let stationImageView: UIImageView = {
1554
let imageView = UIImageView()
1655
imageView.contentMode = .scaleAspectFill
1756
imageView.clipsToBounds = true
18-
NSLayoutConstraint.activate([
19-
imageView.heightAnchor.constraint(equalToConstant: 75),
20-
imageView.widthAnchor.constraint(equalToConstant: 110)
21-
])
57+
imageView.layer.cornerRadius = 12
58+
imageView.translatesAutoresizingMaskIntoConstraints = false
2259
return imageView
2360
}()
24-
61+
62+
private let artworkShadowView: UIView = {
63+
let view = UIView()
64+
view.layer.shadowColor = UIColor.black.cgColor
65+
view.layer.shadowOpacity = 0.35
66+
view.layer.shadowOffset = CGSize(width: 0, height: 3)
67+
view.layer.shadowRadius = 8
68+
view.layer.cornerRadius = 12
69+
view.translatesAutoresizingMaskIntoConstraints = false
70+
return view
71+
}()
72+
2573
let titleLabel: UILabel = {
2674
let label = UILabel()
27-
label.font = .preferredFont(forTextStyle: .title3)
28-
label.numberOfLines = 2
75+
label.font = .preferredFont(forTextStyle: .headline)
76+
label.numberOfLines = 1
2977
label.translatesAutoresizingMaskIntoConstraints = false
3078
return label
3179
}()
32-
80+
81+
private let subtitleStack: UIStackView = {
82+
let stack = UIStackView()
83+
stack.axis = .horizontal
84+
stack.spacing = 6
85+
stack.alignment = .center
86+
stack.translatesAutoresizingMaskIntoConstraints = false
87+
return stack
88+
}()
89+
3390
let subtitleLabel: UILabel = {
3491
let label = UILabel()
35-
label.font = .preferredFont(forTextStyle: .footnote)
92+
label.font = .preferredFont(forTextStyle: .subheadline)
93+
label.textColor = .white.withAlphaComponent(0.55)
94+
label.numberOfLines = 1
3695
label.translatesAutoresizingMaskIntoConstraints = false
3796
return label
3897
}()
39-
98+
99+
private let equalizerView: NVActivityIndicatorView = {
100+
let view = NVActivityIndicatorView(frame: .zero, type: .audioEqualizer, color: .white, padding: nil)
101+
view.translatesAutoresizingMaskIntoConstraints = false
102+
NSLayoutConstraint.activate([
103+
view.widthAnchor.constraint(equalToConstant: 16),
104+
view.heightAnchor.constraint(equalToConstant: 12)
105+
])
106+
view.alpha = 0
107+
return view
108+
}()
109+
110+
private var isAnimatingPulse = false
111+
112+
// MARK: - Init
113+
40114
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
41115
super.init(style: style, reuseIdentifier: reuseIdentifier)
42116
setupViews()
43117
}
44-
118+
119+
required init?(coder: NSCoder) {
120+
fatalError("init(coder:) has not been implemented")
121+
}
122+
45123
override func prepareForReuse() {
46124
super.prepareForReuse()
47-
titleLabel.text = nil
48-
subtitleLabel.text = nil
125+
titleLabel.text = nil
126+
subtitleLabel.text = nil
49127
stationImageView.image = nil
128+
stopPulseAnimation()
129+
pulseRingView.alpha = 0
130+
equalizerView.stopAnimating()
131+
equalizerView.alpha = 0
50132
}
51-
52-
required init?(coder: NSCoder) {
53-
fatalError("init(coder:) has not been implemented")
133+
134+
// MARK: - Highlight / Tap Feedback
135+
136+
override func setHighlighted(_ highlighted: Bool, animated: Bool) {
137+
super.setHighlighted(highlighted, animated: animated)
138+
let scale: CGFloat = highlighted ? 0.97 : 1.0
139+
UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.5) {
140+
self.cardView.transform = CGAffineTransform(scaleX: scale, y: scale)
141+
self.cardBlurView.transform = CGAffineTransform(scaleX: scale, y: scale)
142+
}
54143
}
55-
144+
145+
// MARK: - Setup
146+
56147
private func setupViews() {
57-
58-
selectionStyle = .default
59-
60-
let vStackView = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel])
61-
vStackView.spacing = 8
148+
selectionStyle = .none
149+
backgroundColor = .clear
150+
contentView.backgroundColor = .clear
151+
152+
// Card shadow container + blur
153+
contentView.addSubview(cardView)
154+
cardView.addSubview(cardBlurView)
155+
156+
// Artwork container: shadow view + pulse ring + image
157+
let artworkContainer = UIView()
158+
artworkContainer.translatesAutoresizingMaskIntoConstraints = false
159+
160+
artworkContainer.addSubview(artworkShadowView)
161+
artworkContainer.addSubview(pulseRingView)
162+
artworkContainer.addSubview(stationImageView)
163+
164+
// Subtitle stack: label + equalizer
165+
subtitleStack.addArrangedSubview(equalizerView)
166+
subtitleStack.addArrangedSubview(subtitleLabel)
167+
168+
// Labels stack
169+
let vStackView = UIStackView(arrangedSubviews: [titleLabel, subtitleStack])
170+
vStackView.spacing = 4
62171
vStackView.axis = .vertical
63172
vStackView.translatesAutoresizingMaskIntoConstraints = false
64-
65-
let hStackView = UIStackView(arrangedSubviews: [stationImageView, vStackView])
66-
hStackView.spacing = 8
173+
174+
// Main horizontal stack
175+
let hStackView = UIStackView(arrangedSubviews: [artworkContainer, vStackView])
176+
hStackView.spacing = 14
67177
hStackView.axis = .horizontal
68178
hStackView.alignment = .center
69179
hStackView.translatesAutoresizingMaskIntoConstraints = false
70-
71-
contentView.addSubview(hStackView)
72-
180+
181+
cardBlurView.contentView.addSubview(hStackView)
182+
183+
let artworkSize: CGFloat = 70
184+
let pulseInset: CGFloat = -4
185+
73186
NSLayoutConstraint.activate([
74-
hStackView.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
75-
hStackView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
76-
hStackView.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
77-
hStackView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor)
187+
// Card inset from contentView
188+
cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 5),
189+
cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -5),
190+
cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
191+
cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
192+
193+
// Blur fills card
194+
cardBlurView.topAnchor.constraint(equalTo: cardView.topAnchor),
195+
cardBlurView.bottomAnchor.constraint(equalTo: cardView.bottomAnchor),
196+
cardBlurView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor),
197+
cardBlurView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor),
198+
199+
// HStack inside blur content
200+
hStackView.topAnchor.constraint(equalTo: cardBlurView.contentView.topAnchor, constant: 12),
201+
hStackView.bottomAnchor.constraint(equalTo: cardBlurView.contentView.bottomAnchor, constant: -12),
202+
hStackView.leadingAnchor.constraint(equalTo: cardBlurView.contentView.leadingAnchor, constant: 14),
203+
hStackView.trailingAnchor.constraint(equalTo: cardBlurView.contentView.trailingAnchor, constant: -14),
204+
205+
// Artwork container size
206+
artworkContainer.widthAnchor.constraint(equalToConstant: artworkSize),
207+
artworkContainer.heightAnchor.constraint(equalToConstant: artworkSize),
208+
209+
// Image fills container
210+
stationImageView.topAnchor.constraint(equalTo: artworkContainer.topAnchor),
211+
stationImageView.bottomAnchor.constraint(equalTo: artworkContainer.bottomAnchor),
212+
stationImageView.leadingAnchor.constraint(equalTo: artworkContainer.leadingAnchor),
213+
stationImageView.trailingAnchor.constraint(equalTo: artworkContainer.trailingAnchor),
214+
215+
// Shadow matches image
216+
artworkShadowView.topAnchor.constraint(equalTo: stationImageView.topAnchor),
217+
artworkShadowView.bottomAnchor.constraint(equalTo: stationImageView.bottomAnchor),
218+
artworkShadowView.leadingAnchor.constraint(equalTo: stationImageView.leadingAnchor),
219+
artworkShadowView.trailingAnchor.constraint(equalTo: stationImageView.trailingAnchor),
220+
221+
// Pulse ring slightly larger than artwork
222+
pulseRingView.topAnchor.constraint(equalTo: stationImageView.topAnchor, constant: pulseInset),
223+
pulseRingView.bottomAnchor.constraint(equalTo: stationImageView.bottomAnchor, constant: -pulseInset),
224+
pulseRingView.leadingAnchor.constraint(equalTo: stationImageView.leadingAnchor, constant: pulseInset),
225+
pulseRingView.trailingAnchor.constraint(equalTo: stationImageView.trailingAnchor, constant: -pulseInset),
78226
])
79227
}
228+
229+
// MARK: - Now Playing
230+
231+
func setNowPlaying(isPlaying: Bool, isCurrentStation: Bool) {
232+
guard isCurrentStation else {
233+
stopPulseAnimation()
234+
pulseRingView.alpha = 0
235+
equalizerView.stopAnimating()
236+
equalizerView.alpha = 0
237+
return
238+
}
239+
240+
if isPlaying {
241+
startPulseAnimation()
242+
equalizerView.startAnimating()
243+
equalizerView.alpha = 0.7
244+
} else {
245+
stopPulseAnimation()
246+
pulseRingView.alpha = 0.25
247+
pulseRingView.transform = .identity
248+
equalizerView.stopAnimating()
249+
equalizerView.alpha = 0.4
250+
}
251+
}
252+
253+
// MARK: - Pulse Animation
254+
255+
private func startPulseAnimation() {
256+
guard !isAnimatingPulse else { return }
257+
isAnimatingPulse = true
258+
259+
pulseRingView.alpha = 0
260+
pulseRingView.transform = .identity
261+
262+
UIView.animate(withDuration: 1.5, delay: 0, options: [.repeat, .autoreverse, .curveEaseInOut]) {
263+
self.pulseRingView.alpha = 0.4
264+
self.pulseRingView.transform = CGAffineTransform(scaleX: 1.06, y: 1.06)
265+
}
266+
}
267+
268+
private func stopPulseAnimation() {
269+
guard isAnimatingPulse else { return }
270+
isAnimatingPulse = false
271+
pulseRingView.layer.removeAllAnimations()
272+
pulseRingView.transform = .identity
273+
}
80274
}
81275

276+
// MARK: - Configuration
277+
82278
extension StationTableViewCell {
83279
func configureStationCell(station: RadioStation) {
84-
85-
// Configure the cell...
86280
titleLabel.text = station.name
87281
subtitleLabel.text = station.desc
88-
282+
89283
station.getImage { [weak self] image in
90284
self?.stationImageView.image = image
91285
}

0 commit comments

Comments
 (0)