Skip to content

Commit 0e6844b

Browse files
committed
Add buffering state with loading overlay on artwork and improved cell updates
1 parent 8887c6a commit 0e6844b

4 files changed

Lines changed: 175 additions & 19 deletions

File tree

SwiftRadio/Cells/StationTableViewCell.swift

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import NVActivityIndicatorView
1212
class StationTableViewCell: UITableViewCell {
1313

1414
// MARK: - UI
15+
private var representedStation: RadioStation?
1516

1617
private let cardBlurView: UIVisualEffectView = {
1718
let blur = UIBlurEffect(style: .systemUltraThinMaterialDark)
@@ -107,6 +108,22 @@ class StationTableViewCell: UITableViewCell {
107108
return view
108109
}()
109110

111+
private let bufferingOverlay: UIView = {
112+
let view = UIView()
113+
view.backgroundColor = UIColor.black.withAlphaComponent(0.5)
114+
view.layer.cornerRadius = 12
115+
view.clipsToBounds = true
116+
view.alpha = 0
117+
view.translatesAutoresizingMaskIntoConstraints = false
118+
return view
119+
}()
120+
121+
private let bufferingIndicator: NVActivityIndicatorView = {
122+
let view = NVActivityIndicatorView(frame: .zero, type: .ballPulse, color: .white, padding: nil)
123+
view.translatesAutoresizingMaskIntoConstraints = false
124+
return view
125+
}()
126+
110127
private var isAnimatingPulse = false
111128

112129
// MARK: - Init
@@ -122,13 +139,16 @@ class StationTableViewCell: UITableViewCell {
122139

123140
override func prepareForReuse() {
124141
super.prepareForReuse()
142+
representedStation = nil
125143
titleLabel.text = nil
126144
subtitleLabel.text = nil
127145
stationImageView.image = nil
128146
stopPulseAnimation()
129147
pulseRingView.alpha = 0
130148
equalizerView.stopAnimating()
131149
equalizerView.alpha = 0
150+
bufferingIndicator.stopAnimating()
151+
bufferingOverlay.alpha = 0
132152
}
133153

134154
// MARK: - Highlight / Tap Feedback
@@ -160,6 +180,8 @@ class StationTableViewCell: UITableViewCell {
160180
artworkContainer.addSubview(artworkShadowView)
161181
artworkContainer.addSubview(pulseRingView)
162182
artworkContainer.addSubview(stationImageView)
183+
artworkContainer.addSubview(bufferingOverlay)
184+
bufferingOverlay.addSubview(bufferingIndicator)
163185

164186
// Subtitle stack: label + equalizer
165187
subtitleStack.addArrangedSubview(equalizerView)
@@ -223,25 +245,51 @@ class StationTableViewCell: UITableViewCell {
223245
pulseRingView.bottomAnchor.constraint(equalTo: stationImageView.bottomAnchor, constant: -pulseInset),
224246
pulseRingView.leadingAnchor.constraint(equalTo: stationImageView.leadingAnchor, constant: pulseInset),
225247
pulseRingView.trailingAnchor.constraint(equalTo: stationImageView.trailingAnchor, constant: -pulseInset),
248+
249+
// Buffering overlay on top of image
250+
bufferingOverlay.topAnchor.constraint(equalTo: stationImageView.topAnchor),
251+
bufferingOverlay.bottomAnchor.constraint(equalTo: stationImageView.bottomAnchor),
252+
bufferingOverlay.leadingAnchor.constraint(equalTo: stationImageView.leadingAnchor),
253+
bufferingOverlay.trailingAnchor.constraint(equalTo: stationImageView.trailingAnchor),
254+
bufferingIndicator.centerXAnchor.constraint(equalTo: bufferingOverlay.centerXAnchor),
255+
bufferingIndicator.centerYAnchor.constraint(equalTo: bufferingOverlay.centerYAnchor),
256+
bufferingIndicator.widthAnchor.constraint(equalToConstant: 30),
257+
bufferingIndicator.heightAnchor.constraint(equalToConstant: 20),
226258
])
227259
}
228260

229261
// MARK: - Now Playing
230262

231-
func setNowPlaying(isPlaying: Bool, isCurrentStation: Bool) {
263+
func setNowPlaying(isPlaying: Bool, isBuffering: Bool, isCurrentStation: Bool) {
232264
guard isCurrentStation else {
233265
stopPulseAnimation()
234266
pulseRingView.alpha = 0
235267
equalizerView.stopAnimating()
236268
equalizerView.alpha = 0
269+
bufferingIndicator.stopAnimating()
270+
bufferingOverlay.alpha = 0
237271
return
238272
}
239273

240-
if isPlaying {
274+
if isBuffering {
275+
// Dark overlay + buffering indicator on artwork
276+
stopPulseAnimation()
277+
pulseRingView.alpha = 0
278+
equalizerView.stopAnimating()
279+
equalizerView.alpha = 0
280+
bufferingIndicator.startAnimating()
281+
bufferingOverlay.alpha = 1
282+
} else if isPlaying {
283+
// Pulse ring + equalizer
284+
bufferingIndicator.stopAnimating()
285+
bufferingOverlay.alpha = 0
241286
startPulseAnimation()
242287
equalizerView.startAnimating()
243288
equalizerView.alpha = 0.7
244289
} else {
290+
// Stopped — subtle indicators
291+
bufferingIndicator.stopAnimating()
292+
bufferingOverlay.alpha = 0
245293
stopPulseAnimation()
246294
pulseRingView.alpha = 0.25
247295
pulseRingView.transform = .identity
@@ -277,11 +325,13 @@ class StationTableViewCell: UITableViewCell {
277325

278326
extension StationTableViewCell {
279327
func configureStationCell(station: RadioStation) {
328+
representedStation = station
280329
titleLabel.text = station.name
281330
subtitleLabel.text = station.desc
282331

283332
station.getImage { [weak self] image in
284-
self?.stationImageView.image = image
333+
guard let self = self, self.representedStation == station else { return }
334+
self.stationImageView.image = image
285335
}
286336
}
287337
}

SwiftRadio/ViewControllers/NowPlayingViewController.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,13 @@ class NowPlayingViewController: UIViewController {
117117

118118
func playerStateDidChange(_ state: FRadioPlayer.State) {
119119
switch state {
120+
case .loading:
121+
albumArtworkView.setBuffering(true)
120122
case .readyToPlay, .loadingFinished:
123+
albumArtworkView.setBuffering(false)
121124
playbackStateDidChange(player.playbackState)
125+
case .error:
126+
albumArtworkView.setBuffering(false)
122127
default:
123128
break
124129
}

SwiftRadio/ViewControllers/StationsViewController.swift

Lines changed: 79 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,37 @@ class StationsViewController: BaseController, Handoffable {
4040
return controller
4141
}()
4242

43-
private let animationView: NVActivityIndicatorView = {
44-
let activityIndicatorView = NVActivityIndicatorView(frame: .zero, type: .audioEqualizer, color: .white, padding: nil)
43+
private var isBuffering = false
44+
45+
private let equalizerView: NVActivityIndicatorView = {
46+
let view = NVActivityIndicatorView(frame: .zero, type: .audioEqualizer, color: .white, padding: nil)
47+
view.translatesAutoresizingMaskIntoConstraints = false
48+
return view
49+
}()
50+
51+
private let bufferingView: NVActivityIndicatorView = {
52+
let view = NVActivityIndicatorView(frame: .zero, type: .ballPulse, color: .white, padding: nil)
53+
view.translatesAutoresizingMaskIntoConstraints = false
54+
return view
55+
}()
56+
57+
private lazy var nowPlayingIndicator: UIView = {
58+
let container = UIView()
59+
container.addSubview(equalizerView)
60+
container.addSubview(bufferingView)
4561
NSLayoutConstraint.activate([
46-
activityIndicatorView.widthAnchor.constraint(equalToConstant: 30),
47-
activityIndicatorView.heightAnchor.constraint(equalToConstant: 20)
62+
container.widthAnchor.constraint(equalToConstant: 30),
63+
container.heightAnchor.constraint(equalToConstant: 20),
64+
equalizerView.centerXAnchor.constraint(equalTo: container.centerXAnchor),
65+
equalizerView.centerYAnchor.constraint(equalTo: container.centerYAnchor),
66+
equalizerView.widthAnchor.constraint(equalTo: container.widthAnchor),
67+
equalizerView.heightAnchor.constraint(equalTo: container.heightAnchor),
68+
bufferingView.centerXAnchor.constraint(equalTo: container.centerXAnchor),
69+
bufferingView.centerYAnchor.constraint(equalTo: container.centerYAnchor),
70+
bufferingView.widthAnchor.constraint(equalTo: container.widthAnchor),
71+
bufferingView.heightAnchor.constraint(equalTo: container.heightAnchor),
4872
])
49-
return activityIndicatorView
73+
return container
5074
}()
5175

5276
private lazy var tableView: UITableView = {
@@ -114,20 +138,45 @@ class StationsViewController: BaseController, Handoffable {
114138
}
115139

116140
guard navigationItem.rightBarButtonItem == nil else { return }
117-
let barButton = UIBarButtonItem(customView: animationView)
141+
let barButton = UIBarButtonItem(customView: nowPlayingIndicator)
118142
barButton.target = self
119143
barButton.action = #selector(nowPlayingBarButtonPressed)
120144

121145
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(nowPlayingBarButtonPressed))
122-
animationView.addGestureRecognizer(tapGesture)
123-
animationView.isUserInteractionEnabled = true
146+
nowPlayingIndicator.addGestureRecognizer(tapGesture)
147+
nowPlayingIndicator.isUserInteractionEnabled = true
124148

125149
navigationItem.rightBarButtonItem = barButton
126-
startNowPlayingAnimation(player.isPlaying)
150+
updateNowPlayingAnimation()
127151
}
128152

129-
private func startNowPlayingAnimation(_ animate: Bool) {
130-
animate ? animationView.startAnimating() : animationView.stopAnimating()
153+
private func updateNowPlayingAnimation() {
154+
if isBuffering {
155+
equalizerView.stopAnimating()
156+
bufferingView.startAnimating()
157+
} else if player.isPlaying {
158+
bufferingView.stopAnimating()
159+
equalizerView.startAnimating()
160+
} else {
161+
equalizerView.stopAnimating()
162+
bufferingView.stopAnimating()
163+
}
164+
}
165+
166+
private func updateVisibleCellsNowPlaying() {
167+
if !Thread.isMainThread {
168+
DispatchQueue.main.async { [weak self] in
169+
self?.updateVisibleCellsNowPlaying()
170+
}
171+
return
172+
}
173+
174+
for case let cell as StationTableViewCell in tableView.visibleCells {
175+
guard let indexPath = tableView.indexPath(for: cell) else { continue }
176+
let station = searchController.isActive ? manager.searchedStations[indexPath.row] : manager.stations[indexPath.row]
177+
let isCurrentStation = station == manager.currentStation
178+
cell.setNowPlaying(isPlaying: player.isPlaying, isBuffering: isBuffering, isCurrentStation: isCurrentStation)
179+
}
131180
}
132181

133182
@objc private func nowPlayingBarButtonPressed() {
@@ -162,7 +211,7 @@ class StationsViewController: BaseController, Handoffable {
162211
extension StationsViewController: UITableViewDataSource {
163212

164213
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
165-
100.0
214+
104.0
166215
}
167216

168217
func numberOfSections(in tableView: UITableView) -> Int {
@@ -189,7 +238,8 @@ extension StationsViewController: UITableViewDataSource {
189238

190239
let station = searchController.isActive ? manager.searchedStations[indexPath.row] : manager.stations[indexPath.row]
191240
cell.configureStationCell(station: station)
192-
cell.setNowPlaying(isPlaying: player.isPlaying, isCurrentStation: station == manager.currentStation)
241+
let isCurrentStation = station == manager.currentStation
242+
cell.setNowPlaying(isPlaying: player.isPlaying, isBuffering: isBuffering, isCurrentStation: isCurrentStation)
193243
return cell
194244
}
195245
}
@@ -232,9 +282,22 @@ extension StationsViewController: UISearchResultsUpdating {
232282

233283
extension StationsViewController: FRadioPlayerObserver {
234284

285+
func radioPlayer(_ player: FRadioPlayer, playerStateDidChange state: FRadioPlayer.State) {
286+
switch state {
287+
case .loading:
288+
isBuffering = true
289+
case .readyToPlay, .loadingFinished, .error:
290+
isBuffering = false
291+
default:
292+
break
293+
}
294+
updateNowPlayingAnimation()
295+
updateVisibleCellsNowPlaying()
296+
}
297+
235298
func radioPlayer(_ player: FRadioPlayer, playbackStateDidChange state: FRadioPlayer.PlaybackState) {
236-
startNowPlayingAnimation(player.isPlaying)
237-
tableView.reloadData()
299+
updateNowPlayingAnimation()
300+
updateVisibleCellsNowPlaying()
238301
}
239302

240303
func radioPlayer(_ player: FRadioPlayer, metadataDidChange metadata: FRadioPlayer.Metadata?) {
@@ -249,7 +312,7 @@ extension StationsViewController: StationsManagerObserver {
249312
}
250313

251314
func stationsManager(_ manager: StationsManager, stationDidChange station: RadioStation?) {
315+
updateVisibleCellsNowPlaying()
252316
updateNowPlayingBarButton(station: station)
253-
tableView.reloadData()
254317
}
255318
}

SwiftRadio/Views/AlbumArtworkView.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import UIKit
1010
import Spring
11+
import NVActivityIndicatorView
1112

1213
class AlbumArtworkView: UIView {
1314

@@ -26,6 +27,19 @@ class AlbumArtworkView: UIView {
2627
return view
2728
}()
2829

30+
private let bufferingOverlay: UIView = {
31+
let view = UIView()
32+
view.backgroundColor = UIColor.black.withAlphaComponent(0.4)
33+
view.alpha = 0
34+
return view
35+
}()
36+
37+
private let bufferingIndicator: NVActivityIndicatorView = {
38+
let view = NVActivityIndicatorView(frame: .zero, type: .ballPulse, color: .white, padding: nil)
39+
view.translatesAutoresizingMaskIntoConstraints = false
40+
return view
41+
}()
42+
2943
override init(frame: CGRect) {
3044
super.init(frame: frame)
3145
setupViews()
@@ -45,6 +59,16 @@ class AlbumArtworkView: UIView {
4559
}
4660
}
4761

62+
func setBuffering(_ isBuffering: Bool) {
63+
if isBuffering {
64+
bufferingIndicator.startAnimating()
65+
UIView.animate(withDuration: 0.3) { self.bufferingOverlay.alpha = 1 }
66+
} else {
67+
UIView.animate(withDuration: 0.3) { self.bufferingOverlay.alpha = 0 }
68+
bufferingIndicator.stopAnimating()
69+
}
70+
}
71+
4872
func setPlaying(_ isPlaying: Bool) {
4973
let scale: CGFloat = isPlaying ? 1.0 : 0.85
5074
UIView.animate(
@@ -69,7 +93,11 @@ class AlbumArtworkView: UIView {
6993
containerView.translatesAutoresizingMaskIntoConstraints = false
7094

7195
imageView.translatesAutoresizingMaskIntoConstraints = false
96+
bufferingOverlay.translatesAutoresizingMaskIntoConstraints = false
97+
7298
containerView.addSubview(imageView)
99+
containerView.addSubview(bufferingOverlay)
100+
bufferingOverlay.addSubview(bufferingIndicator)
73101
addSubview(containerView)
74102

75103
NSLayoutConstraint.activate([
@@ -87,6 +115,16 @@ class AlbumArtworkView: UIView {
87115
imageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
88116
imageView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
89117
imageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
118+
119+
// Buffering overlay fills container
120+
bufferingOverlay.topAnchor.constraint(equalTo: containerView.topAnchor),
121+
bufferingOverlay.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
122+
bufferingOverlay.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
123+
bufferingOverlay.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
124+
bufferingIndicator.centerXAnchor.constraint(equalTo: bufferingOverlay.centerXAnchor),
125+
bufferingIndicator.centerYAnchor.constraint(equalTo: bufferingOverlay.centerYAnchor),
126+
bufferingIndicator.widthAnchor.constraint(equalToConstant: 40),
127+
bufferingIndicator.heightAnchor.constraint(equalToConstant: 30),
90128
])
91129
}
92130
}

0 commit comments

Comments
 (0)