Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
319 changes: 318 additions & 1 deletion Amperfy/Screens/ViewController/PlaylistDetailVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,280 @@ class PlaylistDetailDiffableDataSource: BasicUITableViewDiffableDataSource {
}
}

// MARK: - PlaylistPlaybackControlsView

private final class PlaylistPlaybackControlsView: UIView {
static let frameHeight: CGFloat = 112

var visibilityChangedCB: VoidFunctionCallback?

private let player = appDelegate.player
private let playerHandler = PlayerUIHandler(player: appDelegate.player, style: .popupPlayer)
private let previousButton = UIButton(type: .system)
private let playButton = UIButton(type: .system)
private let nextButton = UIButton(type: .system)
private let volumeDownButton = UIButton(type: .system)
private let volumeUpButton = UIButton(type: .system)
private let volumeSlider = UISlider()
private let volumeValueLabel = UILabel()
private let separatorView = UIView()

var shouldDisplay: Bool {
player.currentlyPlaying != nil ||
player.prevQueueCount > 0 ||
player.userQueueCount > 0 ||
player.nextQueueCount > 0
}

override init(frame: CGRect) {
super.init(frame: frame)
setup()
player.addNotifier(notifier: self)
refreshDisplayState()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

private func setup() {
backgroundColor = .secondarySystemBackground
directionalLayoutMargins = NSDirectionalEdgeInsets(
top: 10,
leading: UIView.defaultMarginCellX,
bottom: 12,
trailing: UIView.defaultMarginCellX
)

separatorView.backgroundColor = .separator
separatorView.translatesAutoresizingMaskIntoConstraints = false
addSubview(separatorView)

configureTransportButton(
previousButton,
image: .backwardFill,
label: "Previous Track",
action: #selector(previousButtonPushed)
)
configureTransportButton(
playButton,
image: .play,
label: "Play or Pause",
action: #selector(playButtonPushed)
)
configureTransportButton(
nextButton,
image: .forwardFill,
label: "Next Track",
action: #selector(nextButtonPushed)
)
configureVolumeButton(
volumeDownButton,
image: .volumeMin,
label: "Volume Down",
action: #selector(volumeDownButtonPushed)
)
configureVolumeButton(
volumeUpButton,
image: .volumeMax,
label: "Volume Up",
action: #selector(volumeUpButtonPushed)
)

volumeSlider.minimumValue = 1
volumeSlider.maximumValue = 100
volumeSlider.isContinuous = true
volumeSlider.tintColor = .label
volumeSlider.translatesAutoresizingMaskIntoConstraints = false
volumeSlider.addTarget(
self,
action: #selector(volumeSliderChanged),
for: .valueChanged
)

volumeValueLabel.font = .preferredFont(forTextStyle: .footnote)
volumeValueLabel.adjustsFontForContentSizeCategory = true
volumeValueLabel.textAlignment = .right
volumeValueLabel.textColor = .secondaryLabel
volumeValueLabel.translatesAutoresizingMaskIntoConstraints = false

let transportStack = UIStackView(arrangedSubviews: [
previousButton,
playButton,
nextButton,
])
transportStack.axis = .horizontal
transportStack.alignment = .center
transportStack.distribution = .equalCentering
transportStack.translatesAutoresizingMaskIntoConstraints = false

let volumeStack = UIStackView(arrangedSubviews: [
volumeDownButton,
volumeSlider,
volumeValueLabel,
volumeUpButton,
])
volumeStack.axis = .horizontal
volumeStack.alignment = .center
volumeStack.spacing = 10
volumeStack.translatesAutoresizingMaskIntoConstraints = false

addSubview(transportStack)
addSubview(volumeStack)

NSLayoutConstraint.activate([
separatorView.topAnchor.constraint(equalTo: topAnchor),
separatorView.leadingAnchor.constraint(equalTo: leadingAnchor),
separatorView.trailingAnchor.constraint(equalTo: trailingAnchor),
separatorView.heightAnchor.constraint(equalToConstant: 0.5),

transportStack.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
transportStack.centerXAnchor.constraint(equalTo: centerXAnchor),
transportStack.widthAnchor.constraint(lessThanOrEqualTo: layoutMarginsGuide.widthAnchor),
transportStack.heightAnchor.constraint(equalToConstant: 44),

previousButton.widthAnchor.constraint(equalToConstant: 72),
playButton.widthAnchor.constraint(equalToConstant: 80),
nextButton.widthAnchor.constraint(equalToConstant: 72),

volumeStack.topAnchor.constraint(equalTo: transportStack.bottomAnchor, constant: 8),
volumeStack.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
volumeStack.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
volumeStack.bottomAnchor.constraint(lessThanOrEqualTo: layoutMarginsGuide.bottomAnchor),

volumeDownButton.widthAnchor.constraint(equalToConstant: 36),
volumeDownButton.heightAnchor.constraint(equalToConstant: 36),
volumeUpButton.widthAnchor.constraint(equalToConstant: 36),
volumeUpButton.heightAnchor.constraint(equalToConstant: 36),
volumeValueLabel.widthAnchor.constraint(equalToConstant: 36),
])
}

private func configureTransportButton(
_ button: UIButton,
image: UIImage,
label: String,
action: Selector
) {
button.setImage(
image.withConfiguration(UIImage.SymbolConfiguration(pointSize: 32, weight: .regular)),
for: .normal
)
button.tintColor = .label
button.accessibilityLabel = label
button.translatesAutoresizingMaskIntoConstraints = false
button.addTarget(self, action: action, for: .touchUpInside)
}

private func configureVolumeButton(
_ button: UIButton,
image: UIImage,
label: String,
action: Selector
) {
button.setImage(
image.withConfiguration(UIImage.SymbolConfiguration(pointSize: 19, weight: .regular)),
for: .normal
)
button.tintColor = .label
button.accessibilityLabel = label
button.translatesAutoresizingMaskIntoConstraints = false
button.addTarget(self, action: action, for: .touchUpInside)
}

func refreshDisplayState() {
let newHiddenValue = !shouldDisplay
if isHidden != newHiddenValue {
isHidden = newHiddenValue
visibilityChangedCB?()
}
refreshControls()
}

private func refreshControls() {
playerHandler.refreshPlayButton(playButton)
playerHandler.refreshPrevNextButtons(previousButton: previousButton, nextButton: nextButton)

let volumeLevel = max(1, min(100, Int(round(player.volume * 100))))
volumeSlider.setValue(Float(volumeLevel), animated: false)
volumeValueLabel.text = "\(volumeLevel)"
}

@objc
private func previousButtonPushed() {
playerHandler.previousButtonPushed()
refreshControls()
}

@objc
private func playButtonPushed() {
playerHandler.playButtonPushed()
refreshControls()
}

@objc
private func nextButtonPushed() {
playerHandler.nextButtonPushed()
refreshControls()
}

@objc
private func volumeDownButtonPushed() {
setVolumeLevel(Int(volumeSlider.value) - 1)
}

@objc
private func volumeUpButtonPushed() {
setVolumeLevel(Int(volumeSlider.value) + 1)
}

@objc
private func volumeSliderChanged() {
setVolumeLevel(Int(round(volumeSlider.value)))
}

private func setVolumeLevel(_ level: Int) {
let clampedLevel = max(1, min(100, level))
player.volume = Float(clampedLevel) / 100.0
volumeSlider.setValue(Float(clampedLevel), animated: false)
volumeValueLabel.text = "\(clampedLevel)"
}
}

// MARK: MusicPlayable

extension PlaylistPlaybackControlsView: MusicPlayable {
func didStartPlayingFromBeginning() {
refreshDisplayState()
}

func didStartPlaying() {
refreshDisplayState()
}

func didPause() {
refreshDisplayState()
}

func didStopPlaying() {
refreshDisplayState()
}

func didElapsedTimeChange() {}

func didPlaylistChange() {
refreshDisplayState()
}

func didArtworkChange() {}

func didShuffleChange() {}

func didRepeatChange() {}

func didPlaybackRateChange() {}
}

// MARK: - PlaylistDetailVC

class PlaylistDetailVC: SingleSnapshotFetchedResultsTableViewController<PlaylistItemMO> {
Expand All @@ -94,6 +368,8 @@ class PlaylistDetailVC: SingleSnapshotFetchedResultsTableViewController<Playlist
private var editButton: UIBarButtonItem!
private var optionsButton: UIBarButtonItem!
var detailOperationsView: GenericDetailTableHeader?
private var fixedPlayerControlsView: PlaylistPlaybackControlsView?
private var fixedPlayerControlsBottomConstraint: NSLayoutConstraint?

init(account: Account, playlist: Playlist) {
self.playlist = playlist
Expand Down Expand Up @@ -187,6 +463,7 @@ class PlaylistDetailVC: SingleSnapshotFetchedResultsTableViewController<Playlist
)

snapshotDidChange = detailOperationsView?.refresh
setupFixedPlayerControls()

containableAtIndexPathCallback = { indexPath in
self.fetchedResultsController.getWrappedEntity(at: indexPath).playable
Expand All @@ -211,7 +488,7 @@ class PlaylistDetailVC: SingleSnapshotFetchedResultsTableViewController<Playlist

override func viewIsAppearing(_ animated: Bool) {
super.viewIsAppearing(animated)
extendSafeAreaToAccountForMiniPlayer()
updateBottomSafeAreaForFixedControls()
if appDelegate.storage.settings.user.isOfflineMode {
tableView.isEditing = false
}
Expand Down Expand Up @@ -305,4 +582,44 @@ class PlaylistDetailVC: SingleSnapshotFetchedResultsTableViewController<Playlist
self.refreshControl?.endRefreshing()
}
}

private func setupFixedPlayerControls() {
#if targetEnvironment(macCatalyst)
return
#else
let fixedPlayerControlsView = PlaylistPlaybackControlsView()
fixedPlayerControlsView.translatesAutoresizingMaskIntoConstraints = false
fixedPlayerControlsView.visibilityChangedCB = { [weak self] in
self?.updateBottomSafeAreaForFixedControls()
}
view.addSubview(fixedPlayerControlsView)
let bottomConstraint = fixedPlayerControlsView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
NSLayoutConstraint.activate([
fixedPlayerControlsView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
fixedPlayerControlsView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
fixedPlayerControlsView.heightAnchor.constraint(
equalToConstant: PlaylistPlaybackControlsView.frameHeight
),
bottomConstraint,
])
self.fixedPlayerControlsView = fixedPlayerControlsView
fixedPlayerControlsBottomConstraint = bottomConstraint
#endif
}

private func updateBottomSafeAreaForFixedControls() {
let miniPlayerSafeAreaExtension = AppDelegate.mainWindowHostVC?.getSafeAreaExtension() ?? 0
let systemBottomSafeArea = max(0, view.safeAreaInsets.bottom - additionalSafeAreaInsets.bottom)
fixedPlayerControlsBottomConstraint?.constant = -(systemBottomSafeArea + miniPlayerSafeAreaExtension)
fixedPlayerControlsView?.refreshDisplayState()
let controlsHeight = fixedPlayerControlsView?.shouldDisplay == true
? PlaylistPlaybackControlsView.frameHeight
: 0
additionalSafeAreaInsets = UIEdgeInsets(
top: 0,
left: 0,
bottom: miniPlayerSafeAreaExtension + controlsHeight,
right: 0
)
}
}