From ae6ac330c8f3fd2d2709638be7e4d66ae8cc4940 Mon Sep 17 00:00:00 2001 From: DCM4711 Date: Sat, 24 Jan 2026 20:14:38 +0100 Subject: [PATCH 001/102] Modify dark mode --- Amperfy.xcodeproj/project.pbxproj | 6 +- Amperfy/Amperfy.entitlements | 4 - .../Basics/AnimatedGradientLayer.swift | 3 +- Amperfy/Screens/Player/MiniPlayerView.swift | 15 +- .../Screens/Player/PlayerControlView.swift | 18 +-- Amperfy/Screens/Player/PlayerUIHandler.swift | 4 +- .../Screens/Player/PopupPlayer+Visuals.swift | 10 +- Amperfy/Screens/View/DirectoryTableCell.swift | 2 +- Amperfy/Screens/View/GenericTableCell.swift | 2 +- .../Screens/View/Lyrics/LyricTableCell.swift | 2 +- Amperfy/Screens/View/PlayableTableCell.swift | 6 +- Amperfy/Screens/View/PlaylistTableCell.swift | 2 +- .../View/PodcastEpisodeTableCell.swift | 2 +- Amperfy/Screens/ViewController/HomeVC.swift | 4 +- .../Screens/ViewController/LibraryVC.swift | 2 +- Amperfy/Screens/ViewController/LoginVC.swift | 6 +- .../iOS-SpotifyEquilizerView/Constants.swift | 9 +- Amperfy/UtilitiesExtensions.swift | 133 +++++++++++++++--- AmperfyKit/Common/Utilities.swift | 33 +++-- 19 files changed, 190 insertions(+), 73 deletions(-) diff --git a/Amperfy.xcodeproj/project.pbxproj b/Amperfy.xcodeproj/project.pbxproj index 4263aa39..7b3c7afa 100644 --- a/Amperfy.xcodeproj/project.pbxproj +++ b/Amperfy.xcodeproj/project.pbxproj @@ -3037,6 +3037,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = Y3DHXE89YY; INFOPLIST_FILE = Amperfy/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -3045,7 +3046,7 @@ ); MARKETING_VERSION = 2.1.0; "MARKETING_VERSION[sdk=macosx*]" = 2.1.0; - PRODUCT_BUNDLE_IDENTIFIER = "de.familie-zimba.amperfy-music"; + PRODUCT_BUNDLE_IDENTIFIER = cc.silversurfer.musifyapp; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -3076,6 +3077,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = Y3DHXE89YY; INFOPLIST_FILE = Amperfy/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -3084,7 +3086,7 @@ ); MARKETING_VERSION = 2.1.0; "MARKETING_VERSION[sdk=macosx*]" = 2.1.0; - PRODUCT_BUNDLE_IDENTIFIER = "de.familie-zimba.amperfy-music"; + PRODUCT_BUNDLE_IDENTIFIER = cc.silversurfer.musifyapp; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/Amperfy/Amperfy.entitlements b/Amperfy/Amperfy.entitlements index 5bd6602e..ee95ab7e 100644 --- a/Amperfy/Amperfy.entitlements +++ b/Amperfy/Amperfy.entitlements @@ -2,10 +2,6 @@ - com.apple.developer.carplay-audio - - com.apple.developer.siri - com.apple.security.app-sandbox com.apple.security.network.client diff --git a/Amperfy/Screens/Basics/AnimatedGradientLayer.swift b/Amperfy/Screens/Basics/AnimatedGradientLayer.swift index 7ae53f10..705f4009 100644 --- a/Amperfy/Screens/Basics/AnimatedGradientLayer.swift +++ b/Amperfy/Screens/Basics/AnimatedGradientLayer.swift @@ -123,7 +123,8 @@ class PopupAnimatedGradientLayer { if inStyle == .dark { return [ coloredCornerColor.getWithLightness(of: lightnessDarkMode).cgColor, - UIColor.black.cgColor, + // 90% black (10% white) for custom dark mode + UIColor(white: 0.1, alpha: 1.0).cgColor, ] } else { return [ diff --git a/Amperfy/Screens/Player/MiniPlayerView.swift b/Amperfy/Screens/Player/MiniPlayerView.swift index 8e2abceb..a24aaa20 100644 --- a/Amperfy/Screens/Player/MiniPlayerView.swift +++ b/Amperfy/Screens/Player/MiniPlayerView.swift @@ -40,13 +40,15 @@ class MiniPlayerView: UIView { fileprivate lazy var artworkOverlay: UIView = { let view = UIView() - view.backgroundColor = .black.withAlphaComponent(0.4) + // 90% black for dark mode overlay + view.backgroundColor = UIColor(white: 0.1, alpha: 0.4) view.isHidden = true let imageView = UIImageView() imageView.translatesAutoresizingMaskIntoConstraints = false imageView.contentMode = .scaleAspectFit - imageView.tintColor = .white + // 90% white for dark mode + imageView.tintColor = UIColor(white: 0.9, alpha: 1.0) imageView.image = .miniPlayer.withRenderingMode(.alwaysTemplate) view.addSubview(imageView) @@ -94,7 +96,7 @@ class MiniPlayerView: UIView { label.textAlignment = .center label.backgroundColor = .clear label.numberOfLines = 1 - label.textColor = .label + label.textColor = .customDarkLabel return label }() @@ -193,7 +195,7 @@ class MiniPlayerView: UIView { .withRenderingMode(.alwaysTemplate), for: .normal ) - button.imageView?.tintColor = .label + button.imageView?.tintColor = .customDarkLabel button.showsMenuAsPrimaryAction = true return button }() @@ -845,8 +847,9 @@ class MiniPlayerView: UIView { func refreshPlayer() { if traitCollection.userInterfaceStyle == .dark { - titleLabel.textColor = .white - subtitleLabel.textColor = .lightGray + // 90% white for dark mode text + titleLabel.textColor = UIColor(white: 0.9, alpha: 1.0) + subtitleLabel.textColor = UIColor(white: 0.7, alpha: 1.0) } else { titleLabel.textColor = .black subtitleLabel.textColor = .darkGray diff --git a/Amperfy/Screens/Player/PlayerControlView.swift b/Amperfy/Screens/Player/PlayerControlView.swift index 8fe55e5d..475e5576 100644 --- a/Amperfy/Screens/Player/PlayerControlView.swift +++ b/Amperfy/Screens/Player/PlayerControlView.swift @@ -101,15 +101,15 @@ class PlayerControlView: UIView { playerHandler = PlayerUIHandler(player: player, style: .popupPlayer) - playButton.imageView?.tintColor = .label - previousButton.tintColor = .label - nextButton.tintColor = .label - skipBackwardButton.tintColor = .label - skipForwardButton.tintColor = .label - airplayButton.tintColor = .label - playerModeButton.tintColor = .label - volumeButton.tintColor = .label - optionsButton.imageView?.tintColor = .label + playButton.imageView?.tintColor = .customDarkLabel + previousButton.tintColor = .customDarkLabel + nextButton.tintColor = .customDarkLabel + skipBackwardButton.tintColor = .customDarkLabel + skipForwardButton.tintColor = .customDarkLabel + airplayButton.tintColor = .customDarkLabel + playerModeButton.tintColor = .customDarkLabel + volumeButton.tintColor = .customDarkLabel + optionsButton.imageView?.tintColor = .customDarkLabel refreshPlayer() playerHandler?.refreshPlayerOptions( optionsButton: optionsButton, diff --git a/Amperfy/Screens/Player/PlayerUIHandler.swift b/Amperfy/Screens/Player/PlayerUIHandler.swift index 2de88e43..36885409 100644 --- a/Amperfy/Screens/Player/PlayerUIHandler.swift +++ b/Amperfy/Screens/Player/PlayerUIHandler.swift @@ -221,7 +221,7 @@ class PlayerUIHandler: NSObject { switch style { case .miniPlayeriOS, .miniPlayerMac: - displayPlaylistButton.tintColor = isSelected ? .tintColor : .label + displayPlaylistButton.tintColor = isSelected ? .tintColor : .customDarkLabel case .popupPlayer: var config = UIButton.Configuration.player(isSelected: isSelected) config.image = .playlistDisplayStyle @@ -235,7 +235,7 @@ class PlayerUIHandler: NSObject { switch style { case .miniPlayeriOS, .miniPlayerMac: - displayLyricsButton.tintColor = isSelected ? .tintColor : .label + displayLyricsButton.tintColor = isSelected ? .tintColor : .customDarkLabel case .popupPlayer: break } diff --git a/Amperfy/Screens/Player/PopupPlayer+Visuals.swift b/Amperfy/Screens/Player/PopupPlayer+Visuals.swift index 9350ce58..8d60c4e5 100644 --- a/Amperfy/Screens/Player/PopupPlayer+Visuals.swift +++ b/Amperfy/Screens/Player/PopupPlayer+Visuals.swift @@ -57,7 +57,7 @@ extension PopupPlayerVC { func refreshOptionButton(button: UIButton, rootView: UIViewController?) { var config = UIButton.Configuration.playerRound() config.image = .ellipsis - config.baseForegroundColor = .label + config.baseForegroundColor = .customDarkLabel button.isEnabled = true button.configuration = config @@ -81,12 +81,12 @@ extension PopupPlayerVC { playableInfo.isSong { config.image = playableInfo.isFavorite ? .heartFill : .heartEmpty config.baseForegroundColor = appDelegate.storage.settings.user - .isOnlineMode ? .redHeart : .label + .isOnlineMode ? .redHeart : .customDarkLabel button.isEnabled = appDelegate.storage.settings.user.isOnlineMode } else if let playableInfo = player.currentlyPlaying, let radio = playableInfo.asRadio { config.image = .followLink - config.baseForegroundColor = .label + config.baseForegroundColor = .customDarkLabel button.isEnabled = radio.siteURL != nil } else { config.image = .heartEmpty @@ -96,7 +96,7 @@ extension PopupPlayerVC { case .podcast: config.image = .info config.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(scale: .large) - config.baseForegroundColor = .label + config.baseForegroundColor = .customDarkLabel button.isEnabled = true } if #available(iOS 17.0, *) { @@ -137,7 +137,7 @@ extension PopupPlayerVC { backgroundImage.image = artwork artworkGradientColors = (try? artwork.dominantColors(max: 2)) ?? [ themePreference.asColor, - UIColor.systemBackground, + UIColor.customDarkBackground, ] applyGradientBackground() } diff --git a/Amperfy/Screens/View/DirectoryTableCell.swift b/Amperfy/Screens/View/DirectoryTableCell.swift index d27ef335..02e74e6e 100644 --- a/Amperfy/Screens/View/DirectoryTableCell.swift +++ b/Amperfy/Screens/View/DirectoryTableCell.swift @@ -100,6 +100,6 @@ class DirectoryTableCell: BasicTableCell { iconImage.isHidden = false } accessoryType = .disclosureIndicator - backgroundColor = .systemBackground + backgroundColor = .customDarkBackground } } diff --git a/Amperfy/Screens/View/GenericTableCell.swift b/Amperfy/Screens/View/GenericTableCell.swift index 63703779..996c4eb7 100644 --- a/Amperfy/Screens/View/GenericTableCell.swift +++ b/Amperfy/Screens/View/GenericTableCell.swift @@ -75,6 +75,6 @@ class GenericTableCell: BasicTableCell { infoLabelWidthConstraint.constant = 140 } accessoryType = .disclosureIndicator - backgroundColor = .systemBackground + backgroundColor = .customDarkBackground } } diff --git a/Amperfy/Screens/View/Lyrics/LyricTableCell.swift b/Amperfy/Screens/View/Lyrics/LyricTableCell.swift index 14afa86a..f11931a2 100644 --- a/Amperfy/Screens/View/Lyrics/LyricTableCell.swift +++ b/Amperfy/Screens/View/Lyrics/LyricTableCell.swift @@ -68,7 +68,7 @@ class LyricTableCellModel { attributes: [ .font: UIFont.boldSystemFont(ofSize: 20), - .foregroundColor: UIColor.label, + .foregroundColor: UIColor.customDarkLabel, ] ) } diff --git a/Amperfy/Screens/View/PlayableTableCell.swift b/Amperfy/Screens/View/PlayableTableCell.swift index d73c351d..fb25ac62 100644 --- a/Amperfy/Screens/View/PlayableTableCell.swift +++ b/Amperfy/Screens/View/PlayableTableCell.swift @@ -213,7 +213,7 @@ class PlayableTableCell: BasicTableCell { #else singleTapGestureRecognizer.isEnabled = (displayMode == .normal) #endif - backgroundColor = .systemBackground + backgroundColor = .customDarkBackground refresh() } @@ -349,7 +349,7 @@ class PlayableTableCell: BasicTableCell { optionsButton.isHidden = !isDisplayOptionButton if isDisplayOptionButton { optionsButton.showsMenuAsPrimaryAction = true - optionsButton.imageView?.tintColor = .label + optionsButton.imageView?.tintColor = .customDarkLabel if let rootView = rootView { let playContext = playContextCb != nil ? { self.playContextCb?(self) } : nil let playIndex = playerIndexCb != nil ? { self.playerIndexCb?(self) } : nil @@ -509,7 +509,7 @@ class PlayableTableCell: BasicTableCell { configurePlayIndicator(playable: playable) deleteButton.isHidden = true refreshSubtitleColor() - optionsButton.imageView?.tintColor = .label + optionsButton.imageView?.tintColor = .customDarkLabel backgroundColor = .clear } } diff --git a/Amperfy/Screens/View/PlaylistTableCell.swift b/Amperfy/Screens/View/PlaylistTableCell.swift index b5426385..41e44ed7 100644 --- a/Amperfy/Screens/View/PlaylistTableCell.swift +++ b/Amperfy/Screens/View/PlaylistTableCell.swift @@ -50,6 +50,6 @@ class PlaylistTableCell: BasicTableCell { ) infoLabel.textAlignment = (traitCollection.horizontalSizeClass == .regular) ? .right : .left accessoryType = .disclosureIndicator - backgroundColor = .systemBackground + backgroundColor = .customDarkBackground } } diff --git a/Amperfy/Screens/View/PodcastEpisodeTableCell.swift b/Amperfy/Screens/View/PodcastEpisodeTableCell.swift index ea159c14..83e8bdc1 100644 --- a/Amperfy/Screens/View/PodcastEpisodeTableCell.swift +++ b/Amperfy/Screens/View/PodcastEpisodeTableCell.swift @@ -112,7 +112,7 @@ class PodcastEpisodeTableCell: BasicTableCell { cacheIconImage.isHidden = true playProgressLabel.textColor = .secondaryLabelColor } - backgroundColor = .systemBackground + backgroundColor = .customDarkBackground } override func prepareForReuse() { diff --git a/Amperfy/Screens/ViewController/HomeVC.swift b/Amperfy/Screens/ViewController/HomeVC.swift index 1a464263..94029379 100644 --- a/Amperfy/Screens/ViewController/HomeVC.swift +++ b/Amperfy/Screens/ViewController/HomeVC.swift @@ -168,7 +168,7 @@ final class HomeVC: UICollectionViewController { // MARK: - CollectionView Setup private func configureCollectionView() { - collectionView.backgroundColor = .systemBackground + collectionView.backgroundColor = .customDarkBackground collectionView.register( UINib(nibName: AlbumCollectionCell.typeName, bundle: .main), forCellWithReuseIdentifier: AlbumCollectionCell.typeName @@ -438,7 +438,7 @@ final class SectionHeaderView: UICollectionReusableView { let lbl = UILabel() lbl.translatesAutoresizingMaskIntoConstraints = false lbl.font = UIFont.preferredFont(forTextStyle: .title3).withWeight(.semibold) - lbl.textColor = .label + lbl.textColor = .customDarkLabel return lbl }() diff --git a/Amperfy/Screens/ViewController/LibraryVC.swift b/Amperfy/Screens/ViewController/LibraryVC.swift index 35ee132b..71a579cf 100644 --- a/Amperfy/Screens/ViewController/LibraryVC.swift +++ b/Amperfy/Screens/ViewController/LibraryVC.swift @@ -45,7 +45,7 @@ class LibraryVC: KeyCommandCollectionViewController { lazy var layoutConfig = { var config = UICollectionLayoutListConfiguration(appearance: .sidebarPlain) - config.backgroundColor = .systemBackground + config.backgroundColor = .customDarkBackground return config }() diff --git a/Amperfy/Screens/ViewController/LoginVC.swift b/Amperfy/Screens/ViewController/LoginVC.swift index 3cc774e0..a4e712ca 100644 --- a/Amperfy/Screens/ViewController/LoginVC.swift +++ b/Amperfy/Screens/ViewController/LoginVC.swift @@ -33,7 +33,7 @@ extension UITextField { clipsToBounds = true layer.cornerRadius = 5 layer.borderWidth = CGFloat(0.5) - layer.borderColor = UIColor.label.cgColor + layer.borderColor = UIColor.customDarkLabel.cgColor borderStyle = .roundedRect font = .systemFont(ofSize: LoginVC.fontSize) @@ -41,7 +41,7 @@ extension UITextField { let imageView = UIImageView(frame: CGRect(x: 5, y: 0, width: 25, height: 25)) imageView.contentMode = .scaleAspectFit imageView.image = image.withRenderingMode(.alwaysTemplate) - imageView.tintColor = .label + imageView.tintColor = .customDarkLabel let leftContainerView = UIView(frame: CGRect(x: 0, y: 0, width: 35, height: 25)) leftContainerView.addSubview(imageView) @@ -471,7 +471,7 @@ class LoginVC: UIViewController { }), ]) - view.backgroundColor = .systemBackground + view.backgroundColor = .customDarkBackground amperfyLabel.translatesAutoresizingMaskIntoConstraints = false iconView.translatesAutoresizingMaskIntoConstraints = false diff --git a/Amperfy/SwiftUI/Settings/Player/iOS-SpotifyEquilizerView/Constants.swift b/Amperfy/SwiftUI/Settings/Player/iOS-SpotifyEquilizerView/Constants.swift index 7ecd25f6..db62638a 100644 --- a/Amperfy/SwiftUI/Settings/Player/iOS-SpotifyEquilizerView/Constants.swift +++ b/Amperfy/SwiftUI/Settings/Player/iOS-SpotifyEquilizerView/Constants.swift @@ -7,4 +7,11 @@ import SwiftUI -let BgColor: Color = .black +// Use custom dark background (90% black) instead of pure black +let BgColor: Color = Color(UIColor { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + return UIColor(white: 0.1, alpha: 1.0) + } else { + return UIColor.systemBackground + } +}) diff --git a/Amperfy/UtilitiesExtensions.swift b/Amperfy/UtilitiesExtensions.swift index 3d038ded..714f8ebc 100644 --- a/Amperfy/UtilitiesExtensions.swift +++ b/Amperfy/UtilitiesExtensions.swift @@ -49,6 +49,105 @@ extension MarqueeLabel { extension UIColor { static let hardLabelColor = UIColor(named: "hardLabelColor") + + // MARK: - Custom Dark Mode Colors (90% values instead of 100%) + + /// Background color: 90% black in dark mode (10% white), standard systemBackground in light mode + static let customDarkBackground: UIColor = UIColor { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + // 90% black = 10% white + return UIColor(white: 0.1, alpha: 1.0) + } else { + return UIColor.systemBackground + } + } + + /// Secondary background color for dark mode + static let customDarkSecondaryBackground: UIColor = UIColor { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + // Slightly lighter than primary background + return UIColor(white: 0.15, alpha: 1.0) + } else { + return UIColor.secondarySystemBackground + } + } + + /// Tertiary background color for dark mode + static let customDarkTertiaryBackground: UIColor = UIColor { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + // Slightly lighter than secondary background + return UIColor(white: 0.2, alpha: 1.0) + } else { + return UIColor.tertiarySystemBackground + } + } + + /// Label color: 90% white in dark mode, standard label in light mode + static let customDarkLabel: UIColor = UIColor { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + // 90% white + return UIColor(white: 0.9, alpha: 1.0) + } else { + return UIColor.label + } + } + + /// Secondary label color for dark mode + static let customDarkSecondaryLabel: UIColor = UIColor { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + // 70% white for secondary text + return UIColor(white: 0.7, alpha: 1.0) + } else { + return UIColor.secondaryLabel + } + } + + /// Tertiary label color for dark mode + static let customDarkTertiaryLabel: UIColor = UIColor { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + // 50% white for tertiary text + return UIColor(white: 0.5, alpha: 1.0) + } else { + return UIColor.tertiaryLabel + } + } + + /// Quaternary label color for dark mode + static let customDarkQuaternaryLabel: UIColor = UIColor { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + // 40% white for quaternary text + return UIColor(white: 0.4, alpha: 1.0) + } else { + return UIColor.quaternaryLabel + } + } + + /// Grouped background color for dark mode + static let customDarkGroupedBackground: UIColor = UIColor { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + return UIColor(white: 0.1, alpha: 1.0) + } else { + return UIColor.systemGroupedBackground + } + } + + /// Secondary grouped background color for dark mode + static let customDarkSecondaryGroupedBackground: UIColor = UIColor { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + return UIColor(white: 0.15, alpha: 1.0) + } else { + return UIColor.secondarySystemGroupedBackground + } + } + + /// Tertiary grouped background color for dark mode + static let customDarkTertiaryGroupedBackground: UIColor = UIColor { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + return UIColor(white: 0.2, alpha: 1.0) + } else { + return UIColor.tertiarySystemGroupedBackground + } + } } extension Color { @@ -61,18 +160,18 @@ extension Color { static let darkText = Color(UIColor.darkText) static let placeholderText = Color(UIColor.placeholderText) - // MARK: - Label Colors + // MARK: - Label Colors (using custom dark mode colors) - static let label = Color(UIColor.label) - static let secondaryLabel = Color(UIColor.secondaryLabel) - static let tertiaryLabel = Color(UIColor.tertiaryLabel) - static let quaternaryLabel = Color(UIColor.quaternaryLabel) + static let label = Color(UIColor.customDarkLabel) + static let secondaryLabel = Color(UIColor.customDarkSecondaryLabel) + static let tertiaryLabel = Color(UIColor.customDarkTertiaryLabel) + static let quaternaryLabel = Color(UIColor.customDarkQuaternaryLabel) - // MARK: - Background Colors + // MARK: - Background Colors (using custom dark mode colors) - static let systemBackground = Color(UIColor.systemBackground) - static let secondarySystemBackground = Color(UIColor.secondarySystemBackground) - static let tertiarySystemBackground = Color(UIColor.tertiarySystemBackground) + static let systemBackground = Color(UIColor.customDarkBackground) + static let secondarySystemBackground = Color(UIColor.customDarkSecondaryBackground) + static let tertiarySystemBackground = Color(UIColor.customDarkTertiaryBackground) // MARK: - Fill Colors @@ -81,11 +180,11 @@ extension Color { static let tertiarySystemFill = Color(UIColor.tertiarySystemFill) static let quaternarySystemFill = Color(UIColor.quaternarySystemFill) - // MARK: - Grouped Background Colors + // MARK: - Grouped Background Colors (using custom dark mode colors) - static let systemGroupedBackground = Color(UIColor.systemGroupedBackground) - static let secondarySystemGroupedBackground = Color(UIColor.secondarySystemGroupedBackground) - static let tertiarySystemGroupedBackground = Color(UIColor.tertiarySystemGroupedBackground) + static let systemGroupedBackground = Color(UIColor.customDarkGroupedBackground) + static let secondarySystemGroupedBackground = Color(UIColor.customDarkSecondaryGroupedBackground) + static let tertiarySystemGroupedBackground = Color(UIColor.customDarkTertiaryGroupedBackground) // MARK: - Gray Colors @@ -128,7 +227,7 @@ extension View { } extension UIColor { - static let slideOverBackgroundColor: UIColor = .systemBackground.withAlphaComponent(0.5) + static let slideOverBackgroundColor: UIColor = .customDarkBackground.withAlphaComponent(0.5) static let hoveredBackgroundColor: UIColor = .systemGray2.withAlphaComponent(0.2) } @@ -136,13 +235,13 @@ extension UIButton.Configuration { static func player(isSelected: Bool) -> UIButton.Configuration { var config = UIButton.Configuration.tinted() if isSelected { - config.background.strokeColor = .label + config.background.strokeColor = .customDarkLabel config.background.strokeWidth = 1.0 config.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(scale: .medium) } config.buttonSize = .small - config.baseForegroundColor = !isSelected ? .label : .systemBackground - config.baseBackgroundColor = !isSelected ? .clear : .label + config.baseForegroundColor = !isSelected ? .customDarkLabel : .customDarkBackground + config.baseBackgroundColor = !isSelected ? .clear : .customDarkLabel config.cornerStyle = .medium return config } diff --git a/AmperfyKit/Common/Utilities.swift b/AmperfyKit/Common/Utilities.swift index d589c5c2..9f0155dd 100644 --- a/AmperfyKit/Common/Utilities.swift +++ b/AmperfyKit/Common/Utilities.swift @@ -336,10 +336,13 @@ extension UIColor { } public static var labelColor: UIColor { - if #available(iOS 13.0, *) { - return UIColor.label - } else { - return UIColor.black + UIColor { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + // 90% white in dark mode + return UIColor(white: 0.9, alpha: 1.0) + } else { + return UIColor.label + } } } @@ -356,18 +359,24 @@ extension UIColor { } public static var secondaryLabelColor: UIColor { - if #available(iOS 13.0, *) { - return UIColor.secondaryLabel - } else { - return UIColor.systemGray + UIColor { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + // 70% white in dark mode + return UIColor(white: 0.7, alpha: 1.0) + } else { + return UIColor.secondaryLabel + } } } public static var backgroundColor: UIColor { - if #available(iOS 13.0, *) { - return UIColor.systemBackground - } else { - return UIColor.white + UIColor { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + // 90% black (10% white) in dark mode + return UIColor(white: 0.1, alpha: 1.0) + } else { + return UIColor.systemBackground + } } } } From 1fd5027175cacee421bba69c9829855437a56a93 Mon Sep 17 00:00:00 2001 From: DCM4711 Date: Sat, 24 Jan 2026 20:30:46 +0100 Subject: [PATCH 002/102] Show star rating in Currently-Playing view --- Amperfy.xcodeproj/project.pbxproj | 4 + .../LargeCurrentlyPlayingPlayerView.swift | 70 +++++++ Amperfy/Screens/View/RatingView.swift | 175 ++++++++++++++++++ .../ViewController/SettingsHostVC.swift | 5 + .../Settings/DisplaySettingsView.swift | 8 + .../SwiftUI/Settings/ObservableSettings.swift | 2 + AmperfyKit/Storage/Settings.swift | 6 + 7 files changed, 270 insertions(+) create mode 100644 Amperfy/Screens/View/RatingView.swift diff --git a/Amperfy.xcodeproj/project.pbxproj b/Amperfy.xcodeproj/project.pbxproj index 7b3c7afa..caa04731 100644 --- a/Amperfy.xcodeproj/project.pbxproj +++ b/Amperfy.xcodeproj/project.pbxproj @@ -278,6 +278,7 @@ 50C16C1A21E704EC00F086F0 /* ArtistDetailVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C16C1921E704EC00F086F0 /* ArtistDetailVC.swift */; }; 50C16C1C21E7051700F086F0 /* AlbumDetailVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C16C1B21E7051700F086F0 /* AlbumDetailVC.swift */; }; 50C16C1E21E7A35C00F086F0 /* GenericTableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C16C1D21E7A35C00F086F0 /* GenericTableCell.swift */; }; + 50RA71NG2F5A01240085CBB3 /* RatingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50RA71NG2F5A01230085CBB3 /* RatingView.swift */; }; 50C16C2621E7CBA500F086F0 /* GenericTableCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 50C16C2521E7CBA500F086F0 /* GenericTableCell.xib */; }; 50C16C2821E8680A00F086F0 /* CommonScreenOperations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C16C2721E8680A00F086F0 /* CommonScreenOperations.swift */; }; 50C171422D0E143200C0C53A /* PlaylistAddLibraryVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C171412D0E143200C0C53A /* PlaylistAddLibraryVC.swift */; }; @@ -965,6 +966,7 @@ 50C16C1921E704EC00F086F0 /* ArtistDetailVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtistDetailVC.swift; sourceTree = ""; }; 50C16C1B21E7051700F086F0 /* AlbumDetailVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumDetailVC.swift; sourceTree = ""; }; 50C16C1D21E7A35C00F086F0 /* GenericTableCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GenericTableCell.swift; sourceTree = ""; }; + 50RA71NG2F5A01230085CBB3 /* RatingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingView.swift; sourceTree = ""; }; 50C16C2121E7A38A00F086F0 /* Identifyable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Identifyable.swift; sourceTree = ""; }; 50C16C2321E7A3AC00F086F0 /* ClockTime.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ClockTime.swift; sourceTree = ""; }; 50C16C2521E7CBA500F086F0 /* GenericTableCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = GenericTableCell.xib; sourceTree = ""; }; @@ -1841,6 +1843,7 @@ 5055AA5626835A9000096B8D /* PodcastEpisodeTableCell.swift */, 508FDE3721F1E14F005A0724 /* NewPlaylistTableHeader.xib */, 508FDE3521F1E130005A0724 /* NewPlaylistTableHeader.swift */, + 50RA71NG2F5A01230085CBB3 /* RatingView.swift */, ); path = View; sourceTree = ""; @@ -2434,6 +2437,7 @@ 5077F936221E6D0E0099CA91 /* DownloadsVC.swift in Sources */, 50791E7F28D363F0006CE6E5 /* InfoBannerView.swift in Sources */, 50C16C1E21E7A35C00F086F0 /* GenericTableCell.swift in Sources */, + 50RA71NG2F5A01240085CBB3 /* RatingView.swift in Sources */, 507414A32F013C94007AEC5D /* PlayPausePlaybackAppEnum.swift in Sources */, 500DA64F2F0873DB005578C7 /* CarPlayCachedTabExtension.swift in Sources */, 505CA2092B88E8F400AA81CD /* BasicCollectionViewController.swift in Sources */, diff --git a/Amperfy/Screens/Player/LargeCurrentlyPlayingPlayerView.swift b/Amperfy/Screens/Player/LargeCurrentlyPlayingPlayerView.swift index 01c934f9..0b6d76bc 100644 --- a/Amperfy/Screens/Player/LargeCurrentlyPlayingPlayerView.swift +++ b/Amperfy/Screens/Player/LargeCurrentlyPlayingPlayerView.swift @@ -129,6 +129,7 @@ class LargeCurrentlyPlayingPlayerView: UIView { private var lyricsView: LyricsView? private var visualizerHostingView: SwiftUIContentView? private var displayElement: LargeDisplayElement = .artwork + private var ratingView: RatingView? @IBOutlet weak var upperContainerView: UIView! @@ -173,6 +174,8 @@ class LargeCurrentlyPlayingPlayerView: UIView { lyricsView = LyricsView() lyricsView!.frame = upperContainerView.bounds + let lyricsTap = UITapGestureRecognizer(target: self, action: #selector(handleLyricsTap(_:))) + lyricsView!.addGestureRecognizer(lyricsTap) upperContainerView.addSubview(lyricsView!) visualizerHostingView = SwiftUIContentView() @@ -185,12 +188,44 @@ class LargeCurrentlyPlayingPlayerView: UIView { ) } + setupRatingView() addSwipeGesturesToArtwork() displayElement = getDisplayElementBasedOnConfig() refresh() } + private func setupRatingView() { + ratingView = RatingView() + ratingView!.translatesAutoresizingMaskIntoConstraints = false + ratingView!.delegate = self + ratingView!.isUserInteractionEnabled = true + // Add directly to self and position between artwork and details + addSubview(ratingView!) + + // Center vertically between artwork bottom and details top using a UILayoutGuide + let spacerGuide = UILayoutGuide() + addLayoutGuide(spacerGuide) + + NSLayoutConstraint.activate([ + // Spacer fills the gap between artwork and details + spacerGuide.topAnchor.constraint(equalTo: artworkImage.bottomAnchor), + spacerGuide.bottomAnchor.constraint(equalTo: detailsContainer.topAnchor), + // Center rating view in the spacer + ratingView!.centerXAnchor.constraint(equalTo: centerXAnchor), + ratingView!.centerYAnchor.constraint(equalTo: spacerGuide.centerYAnchor), + ratingView!.heightAnchor.constraint(equalToConstant: 36), + ratingView!.widthAnchor.constraint(equalToConstant: 180), + ]) + } + + @objc + private func handleLyricsTap(_ gesture: UITapGestureRecognizer) { + // Toggle back to artwork when lyrics are tapped + appDelegate.storage.settings.user.isPlayerLyricsDisplayed = false + display(element: .artwork) + } + private func addSwipeGesturesToArtwork() { func createLeftSwipe() -> UISwipeGestureRecognizer { let swipeLeft = UISwipeGestureRecognizer( @@ -368,9 +403,22 @@ class LargeCurrentlyPlayingPlayerView: UIView { ) rootView?.refreshFavoriteButton(button: favoriteButton) rootView?.refreshOptionButton(button: optionsButton, rootView: rootView) + refreshRating() display(element: displayElement) } + func refreshRating() { + guard appDelegate.storage.settings.user.isShowRating, + let song = rootView?.player.currentlyPlaying?.asSong else { + ratingView?.setRating(0, animated: false) + ratingView?.isHidden = true + return + } + // Keep rating hidden when lyrics are displayed + ratingView?.isHidden = (displayElement == .lyrics) + ratingView?.setRating(song.rating, animated: false) + } + func refreshArtwork() { rootView?.playerHandler?.refreshArtwork(artworkImage: artworkImage) } @@ -404,3 +452,25 @@ class LargeCurrentlyPlayingPlayerView: UIView { rootView?.refreshFavoriteButton(button: favoriteButton) } } + +// MARK: - RatingViewDelegate + +extension LargeCurrentlyPlayingPlayerView: RatingViewDelegate { + func ratingView(_ ratingView: RatingView, didChangeRating rating: Int) { + guard let song = rootView?.player.currentlyPlaying?.asSong, + let account = song.account + else { return } + + // Update local rating immediately for responsive UI + song.rating = rating + + // Sync rating to server + Task { + do { + try await appDelegate.getMeta(account.info).librarySyncer.setRating(song: song, rating: rating) + } catch { + appDelegate.eventLogger.report(topic: "Song Rating", error: error) + } + } + } +} diff --git a/Amperfy/Screens/View/RatingView.swift b/Amperfy/Screens/View/RatingView.swift new file mode 100644 index 00000000..e0f4ef1d --- /dev/null +++ b/Amperfy/Screens/View/RatingView.swift @@ -0,0 +1,175 @@ +// +// RatingView.swift +// Amperfy +// +// Created by Maximilian Bauer on 21.01.26. +// Copyright (c) 2026 Maximilian Bauer. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +import AmperfyKit +import UIKit + +// MARK: - RatingViewDelegate + +@MainActor +protocol RatingViewDelegate: AnyObject { + func ratingView(_ ratingView: RatingView, didChangeRating rating: Int) +} + +// MARK: - RatingView + +class RatingView: UIView { + // MARK: - Properties + + weak var delegate: RatingViewDelegate? + + private var starButtons: [UIButton] = [] + private let starCount = 5 + private let starSize: CGFloat = 25 // 10% smaller than original 28 + private let starSpacing: CGFloat = 4 + + /// Star color that adapts to light/dark mode - black for light, 90% white for dark + private static var starColor: UIColor { + UIColor { traitCollection in + if traitCollection.userInterfaceStyle == .light { + return .black + } else { + return UIColor(white: 0.9, alpha: 1.0) + } + } + } + + private(set) var rating: Int = 0 { + didSet { + updateStarDisplay() + } + } + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupView() + } + + // MARK: - Setup + + private func setupView() { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = starSpacing + stackView.alignment = .center + stackView.distribution = .equalSpacing + stackView.translatesAutoresizingMaskIntoConstraints = false + + addSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.centerXAnchor.constraint(equalTo: centerXAnchor), + stackView.centerYAnchor.constraint(equalTo: centerYAnchor), + stackView.topAnchor.constraint(greaterThanOrEqualTo: topAnchor), + stackView.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor), + ]) + + // Create star buttons + for i in 0 ..< starCount { + let button = UIButton(type: .system) + button.tag = i + 1 // Tags 1-5 represent star ratings + button.tintColor = Self.starColor + button.setImage(.starEmpty, for: .normal) + + let config = UIImage.SymbolConfiguration(pointSize: starSize, weight: .regular) + button.setPreferredSymbolConfiguration(config, forImageIn: .normal) + + button.addTarget(self, action: #selector(starTapped(_:)), for: .touchUpInside) + + // Add long press to clear rating + let longPress = UILongPressGestureRecognizer( + target: self, + action: #selector(starLongPressed(_:)) + ) + longPress.minimumPressDuration = 0.5 + button.addGestureRecognizer(longPress) + + starButtons.append(button) + stackView.addArrangedSubview(button) + } + + updateStarDisplay() + } + + // MARK: - Actions + + @objc + private func starTapped(_ sender: UIButton) { + let newRating = sender.tag + + // If tapping the same star that represents current rating, clear it + if newRating == rating { + setRating(0, animated: true) + } else { + setRating(newRating, animated: true) + } + + delegate?.ratingView(self, didChangeRating: rating) + + // Haptic feedback + let generator = UIImpactFeedbackGenerator(style: .light) + generator.impactOccurred() + } + + @objc + private func starLongPressed(_ sender: UILongPressGestureRecognizer) { + guard sender.state == .began else { return } + + setRating(0, animated: true) + delegate?.ratingView(self, didChangeRating: rating) + + // Haptic feedback + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.impactOccurred() + } + + // MARK: - Public Methods + + func setRating(_ newRating: Int, animated: Bool = false) { + let clampedRating = max(0, min(starCount, newRating)) + + if animated && clampedRating != rating { + // Animate the change + UIView.animate(withDuration: 0.15) { + self.rating = clampedRating + } + } else { + rating = clampedRating + } + } + + // MARK: - Private Methods + + private func updateStarDisplay() { + for (index, button) in starButtons.enumerated() { + let starNumber = index + 1 + let isFilled = starNumber <= rating + button.setImage(isFilled ? .starFill : .starEmpty, for: .normal) + } + } +} diff --git a/Amperfy/Screens/ViewController/SettingsHostVC.swift b/Amperfy/Screens/ViewController/SettingsHostVC.swift index 478da61e..b1a3116c 100755 --- a/Amperfy/Screens/ViewController/SettingsHostVC.swift +++ b/Amperfy/Screens/ViewController/SettingsHostVC.swift @@ -118,6 +118,11 @@ class SettingsHostVC: UIViewController { self.appDelegate.storage.settings.user.isShowArtistDuration = newValue })) + settings.isShowRating = appDelegate.storage.settings.user.isShowRating + changesAgent.append(settings.$isShowRating.sink(receiveValue: { newValue in + self.appDelegate.storage.settings.user.isShowRating = newValue + })) + settings.isPlayerShuffleButtonEnabled = appDelegate.storage.settings.user .isPlayerShuffleButtonEnabled changesAgent.append(settings.$isPlayerShuffleButtonEnabled.sink(receiveValue: { newValue in diff --git a/Amperfy/SwiftUI/Settings/DisplaySettingsView.swift b/Amperfy/SwiftUI/Settings/DisplaySettingsView.swift index 1589f456..8ac631c3 100644 --- a/Amperfy/SwiftUI/Settings/DisplaySettingsView.swift +++ b/Amperfy/SwiftUI/Settings/DisplaySettingsView.swift @@ -127,6 +127,14 @@ struct DisplaySettingsView: View { "Display artist duration in table rows." ) + SettingsSection( + content: { + SettingsCheckBoxRow(title: "Show Star Rating", isOn: $settings.isShowRating) + }, + footer: + "Display star rating in song cells and the currently playing view." + ) + SettingsSection( content: { SettingsCheckBoxRow( diff --git a/Amperfy/SwiftUI/Settings/ObservableSettings.swift b/Amperfy/SwiftUI/Settings/ObservableSettings.swift index 20bf50bf..8067f4ee 100755 --- a/Amperfy/SwiftUI/Settings/ObservableSettings.swift +++ b/Amperfy/SwiftUI/Settings/ObservableSettings.swift @@ -34,6 +34,8 @@ final class Settings: ObservableObject { @Published var isShowArtistDuration = false @Published + var isShowRating = true + @Published var isPlayerShuffleButtonEnabled = true @Published var screenLockPreventionPreference: ScreenLockPreventionPreference = .defaultValue diff --git a/AmperfyKit/Storage/Settings.swift b/AmperfyKit/Storage/Settings.swift index b2a2dfde..31093239 100644 --- a/AmperfyKit/Storage/Settings.swift +++ b/AmperfyKit/Storage/Settings.swift @@ -100,6 +100,12 @@ public struct UserSettings: Sendable, Codable { set { _isShowArtistDuration = newValue } } + private var _isShowRating: Bool = true + public var isShowRating: Bool { + get { _isShowRating } + set { _isShowRating = newValue } + } + private var _isPlayerShuffleButtonEnabled: Bool = true public var isPlayerShuffleButtonEnabled: Bool { get { _isPlayerShuffleButtonEnabled } From 19a94788086c4d127cd745b9d84c50d3be851c2b Mon Sep 17 00:00:00 2001 From: DCM4711 Date: Sat, 24 Jan 2026 20:41:13 +0100 Subject: [PATCH 003/102] Show star ratings in song-list views --- Amperfy/Screens/View/PlayableTableCell.swift | 63 ++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/Amperfy/Screens/View/PlayableTableCell.swift b/Amperfy/Screens/View/PlayableTableCell.swift index fb25ac62..6eee56e3 100644 --- a/Amperfy/Screens/View/PlayableTableCell.swift +++ b/Amperfy/Screens/View/PlayableTableCell.swift @@ -80,6 +80,9 @@ class PlayableTableCell: BasicTableCell { @IBOutlet weak var playOverNumberButton: UIButton! + private var ratingStackView: UIStackView? + private var ratingStarViews: [UIImageView] = [] + static let rowHeight: CGFloat = 48 + margin.bottom + margin.top private static let touchAnimation = 0.4 @@ -141,15 +144,68 @@ class PlayableTableCell: BasicTableCell { playOverArtworkButton.layer.cornerRadius = CornerRadius.small.asCGFloat selectionStyle = .none downloadProgress.isHidden = true + setupRatingStars() resetForReuse() } } + private func setupRatingStars() { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = -2 // Negative spacing to put stars closer together + stackView.alignment = .center + stackView.distribution = .fill + stackView.translatesAutoresizingMaskIntoConstraints = false + + // Create 5 star image views + for _ in 0 ..< 5 { + let starView = UIImageView() + starView.contentMode = .scaleAspectFit + starView.translatesAutoresizingMaskIntoConstraints = false + starView.image = .starEmpty + starView.tintColor = UIColor(red: 0.882, green: 0.686, blue: 0.255, alpha: 1.0) // #e1af41 + let starSize: CGFloat = 10 + NSLayoutConstraint.activate([ + starView.widthAnchor.constraint(equalToConstant: starSize), + starView.heightAnchor.constraint(equalToConstant: starSize), + ]) + ratingStarViews.append(starView) + stackView.addArrangedSubview(starView) + } + + contentView.addSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.trailingAnchor.constraint(equalTo: optionsButton.trailingAnchor, constant: -4), + stackView.topAnchor.constraint(equalTo: optionsButton.bottomAnchor, constant: -8), + ]) + + ratingStackView = stackView + } + + private func updateRatingDisplay(rating: Int) { + // Show the LAST N stars (right-aligned) instead of first N + // This keeps stars right-justified regardless of rating + let startIndex = 5 - rating + for (index, starView) in ratingStarViews.enumerated() { + starView.isHidden = index < startIndex + starView.image = .starFill + } + + // Only show rating if song has a rating > 0 + ratingStackView?.isHidden = (rating == 0) + } + func resetForReuse() { playIndicator?.reset() deleteButton.isHidden = true playOverArtworkButton.isHidden = true playOverNumberButton.isHidden = true + ratingStackView?.isHidden = true + // Reset all stars for next use + for starView in ratingStarViews { + starView.isHidden = false + } } override func prepareForReuse() { @@ -320,6 +376,13 @@ class PlayableTableCell: BasicTableCell { refreshSubtitleColor() refreshCacheAndDuration() + + // Update rating display for songs (only if setting is enabled) + if appDelegate.storage.settings.user.isShowRating, let song = playable.asSong { + updateRatingDisplay(rating: song.rating) + } else { + ratingStackView?.isHidden = true + } } private func configureTrackNumberLabel() { From a152c7cb2e8a44d42cdd7bbfdc5a20d534303313 Mon Sep 17 00:00:00 2001 From: DCM4711 Date: Sat, 24 Jan 2026 20:57:18 +0100 Subject: [PATCH 004/102] Show/hide lyrics by clicking on album art in currently-playing view --- .../LargeCurrentlyPlayingPlayerView.swift | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/Amperfy/Screens/Player/LargeCurrentlyPlayingPlayerView.swift b/Amperfy/Screens/Player/LargeCurrentlyPlayingPlayerView.swift index 0b6d76bc..35cfe697 100644 --- a/Amperfy/Screens/Player/LargeCurrentlyPlayingPlayerView.swift +++ b/Amperfy/Screens/Player/LargeCurrentlyPlayingPlayerView.swift @@ -221,9 +221,12 @@ class LargeCurrentlyPlayingPlayerView: UIView { @objc private func handleLyricsTap(_ gesture: UITapGestureRecognizer) { - // Toggle back to artwork when lyrics are tapped - appDelegate.storage.settings.user.isPlayerLyricsDisplayed = false - display(element: .artwork) + // Hide lyrics when tapped + if displayElement == .lyrics { + appDelegate.storage.settings.user.isPlayerLyricsDisplayed = false + display(element: .artwork) + refreshRating() + } } private func addSwipeGesturesToArtwork() { @@ -245,14 +248,30 @@ class LargeCurrentlyPlayingPlayerView: UIView { return swipeRight } + func createTap() -> UITapGestureRecognizer { + UITapGestureRecognizer(target: self, action: #selector(handleArtworkTap(_:))) + } + artworkImage.isUserInteractionEnabled = true artworkImage.addGestureRecognizer(createLeftSwipe()) artworkImage.addGestureRecognizer(createRightSwipe()) + artworkImage.addGestureRecognizer(createTap()) visualizerHostingView?.hostingController?.view.isUserInteractionEnabled = true visualizerHostingView?.hostingController?.view.addGestureRecognizer(createRightSwipe()) visualizerHostingView?.hostingController?.view.addGestureRecognizer(createLeftSwipe()) } + @objc + private func handleArtworkTap(_ gesture: UITapGestureRecognizer) { + // Toggle lyrics when artwork is tapped + if isLyricsButtonAllowedToDisplay && displayElement != .lyrics { + appDelegate.storage.settings.user.isPlayerLyricsDisplayed = true + appDelegate.storage.settings.user.isPlayerVisualizerDisplayed = false + display(element: .lyrics) + ratingView?.isHidden = true + } + } + @objc private func handleSwipe(_ gesture: UISwipeGestureRecognizer) { switch gesture.direction { From 717d1835d1cc8273e58c7bcc516721711f8c30e8 Mon Sep 17 00:00:00 2001 From: DCM4711 Date: Sat, 24 Jan 2026 21:06:03 +0100 Subject: [PATCH 005/102] When showing lyrics jump to the current lrc lyrics position without scrolling --- Amperfy/Screens/View/Lyrics/LyricsView.swift | 13 +++++++++++-- Amperfy/SwiftUI/Settings/DisplaySettingsView.swift | 3 ++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Amperfy/Screens/View/Lyrics/LyricsView.swift b/Amperfy/Screens/View/Lyrics/LyricsView.swift index 7dd77e9f..93b04746 100644 --- a/Amperfy/Screens/View/Lyrics/LyricsView.swift +++ b/Amperfy/Screens/View/Lyrics/LyricsView.swift @@ -31,6 +31,7 @@ class LyricsView: UITableView, UITableViewDataSource, UITableViewDelegate { private var lineSpacing: CGFloat = 16 private var hasLastLyricsLineAlreadyDisplayedOnce = false private var scrollAnimation = true + private var isFirstScroll = true // Track if this is the first scroll after display override init(frame: CGRect, style: UITableView.Style) { super.init(frame: frame, style: style) @@ -135,6 +136,7 @@ class LyricsView: UITableView, UITableViewDataSource, UITableViewDelegate { lyricModels.removeAll() lastIndex = nil hasLastLyricsLineAlreadyDisplayedOnce = false + isFirstScroll = true // Reset so first scroll jumps immediately reloadInsets() guard let lyrics = lyrics else { @@ -164,12 +166,19 @@ class LyricsView: UITableView, UITableViewDataSource, UITableViewDelegate { lyrics.synced // if the lyrics are not synced -> only display else { return } + // First scroll should jump immediately without animation + // Subsequent scrolls use the user's scroll animation preference + let shouldAnimate = isFirstScroll ? false : scrollAnimation + if isFirstScroll { + isFirstScroll = false + } + guard let indexOfNextLine = lyrics.line.firstIndex(where: { $0.startTime >= time }) else { if !hasLastLyricsLineAlreadyDisplayedOnce { scrollToRow( at: IndexPath(row: lyricModels.count - 1, section: 0), at: .middle, - animated: scrollAnimation + animated: shouldAnimate ) hasLastLyricsLineAlreadyDisplayedOnce = true } @@ -205,7 +214,7 @@ class LyricsView: UITableView, UITableViewDataSource, UITableViewDelegate { scrollToRow( at: IndexPath(row: indexOfCurrentLine, section: 0), at: .middle, - animated: scrollAnimation + animated: shouldAnimate ) } } diff --git a/Amperfy/SwiftUI/Settings/DisplaySettingsView.swift b/Amperfy/SwiftUI/Settings/DisplaySettingsView.swift index 8ac631c3..691cf924 100644 --- a/Amperfy/SwiftUI/Settings/DisplaySettingsView.swift +++ b/Amperfy/SwiftUI/Settings/DisplaySettingsView.swift @@ -129,7 +129,8 @@ struct DisplaySettingsView: View { SettingsSection( content: { - SettingsCheckBoxRow(title: "Show Star Rating", isOn: $settings.isShowRating) + SettingsCheckBoxRow(title: "Show Star + Rating", isOn: $settings.isShowRating) }, footer: "Display star rating in song cells and the currently playing view." From f88490a4c02aaffaaf34a2a7c7e053577e029432 Mon Sep 17 00:00:00 2001 From: DCM4711 Date: Sat, 24 Jan 2026 21:17:41 +0100 Subject: [PATCH 006/102] Keep current pause/play state when clicking next (>>) or prev (<<) song --- .../Settings/DisplaySettingsView.swift | 3 +- AmperfyKit/Player/AudioPlayer.swift | 36 +++++++++++++------ 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/Amperfy/SwiftUI/Settings/DisplaySettingsView.swift b/Amperfy/SwiftUI/Settings/DisplaySettingsView.swift index 691cf924..8ac631c3 100644 --- a/Amperfy/SwiftUI/Settings/DisplaySettingsView.swift +++ b/Amperfy/SwiftUI/Settings/DisplaySettingsView.swift @@ -129,8 +129,7 @@ struct DisplaySettingsView: View { SettingsSection( content: { - SettingsCheckBoxRow(title: "Show Star - Rating", isOn: $settings.isShowRating) + SettingsCheckBoxRow(title: "Show Star Rating", isOn: $settings.isShowRating) }, footer: "Display star rating in song cells and the currently playing view." diff --git a/AmperfyKit/Player/AudioPlayer.swift b/AmperfyKit/Player/AudioPlayer.swift index 2feed408..b865b170 100644 --- a/AmperfyKit/Player/AudioPlayer.swift +++ b/AmperfyKit/Player/AudioPlayer.swift @@ -96,24 +96,26 @@ public class AudioPlayer: NSObject, BackendAudioPlayerNotifiable { return backendAudioPlayer.elapsedTime >= Self.replayInsteadPlayPreviousTimeInSec } - private func replayCurrentItem() { + private func replayCurrentItem(autoStartPlayback: Bool? = nil) { os_log(.debug, "Replay") if let currentPlayable = currentlyPlaying { - insertIntoPlayer(playable: currentPlayable) + insertIntoPlayer(playable: currentPlayable, autoStartPlayback: autoStartPlayback) } notifyItemStartedPlayingFromBeginning() } - private func insertIntoPlayer(playable: AbstractPlayable) { + private func insertIntoPlayer(playable: AbstractPlayable, autoStartPlayback: Bool? = nil) { userStatistics.playedItem( repeatMode: playerStatus.repeatMode, isShuffle: playerStatus.isShuffle ) playable.countPlayed() + // Use provided autoStartPlayback, or fall back to user setting + let shouldAutoStart = autoStartPlayback ?? !settings.user.isPlaybackStartOnlyOnPlay backendAudioPlayer.requestToPlay( playable: playable, playbackRate: playerStatus.playbackRate, - autoStartPlayback: !settings.user.isPlaybackStartOnlyOnPlay + autoStartPlayback: shouldAutoStart ) } @@ -175,17 +177,19 @@ public class AudioPlayer: NSObject, BackendAudioPlayerNotifiable { } } - func play(playerIndex: PlayerIndex) { + func play(playerIndex: PlayerIndex, autoStartPlayback: Bool? = nil) { guard let playable = queueHandler.markAndGetPlayableAsPlaying(at: playerIndex) else { stop() return } - insertIntoPlayer(playable: playable) + insertIntoPlayer(playable: playable, autoStartPlayback: autoStartPlayback) } func playPreviousOrReplay() { + // Preserve the current play/pause state when switching tracks + let wasPlaying = backendAudioPlayer.isPlaying if shouldCurrentItemReplayedInsteadOfPrevious() { - replayCurrentItem() + replayCurrentItem(autoStartPlayback: wasPlaying) } else { playPrevious() } @@ -193,19 +197,29 @@ public class AudioPlayer: NSObject, BackendAudioPlayerNotifiable { // BackendAudioPlayerNotifiable func playPrevious() { + // Preserve the current play/pause state when switching tracks + let wasPlaying = backendAudioPlayer.isPlaying if queueHandler.prevQueueCount > 0 { - play(playerIndex: PlayerIndex(queueType: .prev, index: queueHandler.prevQueueCount - 1)) + play( + playerIndex: PlayerIndex(queueType: .prev, index: queueHandler.prevQueueCount - 1), + autoStartPlayback: wasPlaying + ) } else if playerStatus.repeatMode == .all, queueHandler.nextQueueCount > 0 { - play(playerIndex: PlayerIndex(queueType: .next, index: queueHandler.nextQueueCount - 1)) + play( + playerIndex: PlayerIndex(queueType: .next, index: queueHandler.nextQueueCount - 1), + autoStartPlayback: wasPlaying + ) } else { - replayCurrentItem() + replayCurrentItem(autoStartPlayback: wasPlaying) } } // BackendAudioPlayerNotifiable func playNext() { + // Preserve the current play/pause state when switching tracks + let wasPlaying = backendAudioPlayer.isPlaying if let nextPlayerIndex = nextPlayerIndex { - play(playerIndex: nextPlayerIndex) + play(playerIndex: nextPlayerIndex, autoStartPlayback: wasPlaying) } else { stop() } From 29f733b38b4faeaca53cc7e0256d831bc395f7a3 Mon Sep 17 00:00:00 2001 From: DCM4711 Date: Sat, 24 Jan 2026 21:40:28 +0100 Subject: [PATCH 007/102] Design change for rating stars in currently-playing view --- Amperfy/Screens/View/RatingView.swift | 71 +++++++++++++++++---------- 1 file changed, 46 insertions(+), 25 deletions(-) diff --git a/Amperfy/Screens/View/RatingView.swift b/Amperfy/Screens/View/RatingView.swift index e0f4ef1d..10526e0a 100644 --- a/Amperfy/Screens/View/RatingView.swift +++ b/Amperfy/Screens/View/RatingView.swift @@ -36,13 +36,14 @@ class RatingView: UIView { weak var delegate: RatingViewDelegate? - private var starButtons: [UIButton] = [] + private var starImageViews: [UIImageView] = [] + private var stackView: UIStackView! private let starCount = 5 - private let starSize: CGFloat = 25 // 10% smaller than original 28 + private let starSize: CGFloat = 25 private let starSpacing: CGFloat = 4 - /// Star color that adapts to light/dark mode - black for light, 90% white for dark - private static var starColor: UIColor { + /// Star color for selected stars - adapts to light/dark mode + private static var selectedStarColor: UIColor { UIColor { traitCollection in if traitCollection.userInterfaceStyle == .light { return .black @@ -52,6 +53,11 @@ class RatingView: UIView { } } + /// Star color for unselected stars - white with 10% opacity + private static var unselectedStarColor: UIColor { + UIColor(white: 1.0, alpha: 0.1) + } + private(set) var rating: Int = 0 { didSet { updateStarDisplay() @@ -73,12 +79,13 @@ class RatingView: UIView { // MARK: - Setup private func setupView() { - let stackView = UIStackView() + stackView = UIStackView() stackView.axis = .horizontal stackView.spacing = starSpacing stackView.alignment = .center stackView.distribution = .equalSpacing stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.isUserInteractionEnabled = true addSubview(stackView) @@ -89,28 +96,35 @@ class RatingView: UIView { stackView.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor), ]) - // Create star buttons + // Create star image views for i in 0 ..< starCount { - let button = UIButton(type: .system) - button.tag = i + 1 // Tags 1-5 represent star ratings - button.tintColor = Self.starColor - button.setImage(.starEmpty, for: .normal) - + let starView = UIImageView() + starView.tag = i + 1 // Tags 1-5 represent star ratings + starView.contentMode = .scaleAspectFit + starView.translatesAutoresizingMaskIntoConstraints = false + starView.isUserInteractionEnabled = true + + // Set the filled star image with pre-baked color (unselected grey) let config = UIImage.SymbolConfiguration(pointSize: starSize, weight: .regular) - button.setPreferredSymbolConfiguration(config, forImageIn: .normal) + let baseImage = UIImage.starFill.withConfiguration(config) + starView.image = baseImage.withTintColor(Self.unselectedStarColor, renderingMode: .alwaysOriginal) + + NSLayoutConstraint.activate([ + starView.widthAnchor.constraint(equalToConstant: starSize + 8), // Add padding for tap area + starView.heightAnchor.constraint(equalToConstant: starSize + 8), + ]) - button.addTarget(self, action: #selector(starTapped(_:)), for: .touchUpInside) + // Add tap gesture + let tap = UITapGestureRecognizer(target: self, action: #selector(starTapped(_:))) + starView.addGestureRecognizer(tap) // Add long press to clear rating - let longPress = UILongPressGestureRecognizer( - target: self, - action: #selector(starLongPressed(_:)) - ) + let longPress = UILongPressGestureRecognizer(target: self, action: #selector(starLongPressed(_:))) longPress.minimumPressDuration = 0.5 - button.addGestureRecognizer(longPress) + starView.addGestureRecognizer(longPress) - starButtons.append(button) - stackView.addArrangedSubview(button) + starImageViews.append(starView) + stackView.addArrangedSubview(starView) } updateStarDisplay() @@ -119,8 +133,9 @@ class RatingView: UIView { // MARK: - Actions @objc - private func starTapped(_ sender: UIButton) { - let newRating = sender.tag + private func starTapped(_ sender: UITapGestureRecognizer) { + guard let starView = sender.view else { return } + let newRating = starView.tag // If tapping the same star that represents current rating, clear it if newRating == rating { @@ -166,10 +181,16 @@ class RatingView: UIView { // MARK: - Private Methods private func updateStarDisplay() { - for (index, button) in starButtons.enumerated() { + let config = UIImage.SymbolConfiguration(pointSize: starSize, weight: .regular) + let baseImage = UIImage.starFill.withConfiguration(config) + + for (index, starView) in starImageViews.enumerated() { let starNumber = index + 1 - let isFilled = starNumber <= rating - button.setImage(isFilled ? .starFill : .starEmpty, for: .normal) + let isSelected = starNumber <= rating + + // Create pre-colored images to bypass tintColor override + let color = isSelected ? Self.selectedStarColor : Self.unselectedStarColor + starView.image = baseImage.withTintColor(color, renderingMode: .alwaysOriginal) } } } From f5f8ac6f3233b3f6b34facd3ace4fe1d74568333 Mon Sep 17 00:00:00 2001 From: DCM4711 Date: Sat, 24 Jan 2026 21:46:49 +0100 Subject: [PATCH 008/102] Fix small bug where music is briefly heard when clicking >> or << while music is paused --- AmperfyKit/Player/BackendAudioPlayer.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/AmperfyKit/Player/BackendAudioPlayer.swift b/AmperfyKit/Player/BackendAudioPlayer.swift index 655f1f65..47c6f14f 100644 --- a/AmperfyKit/Player/BackendAudioPlayer.swift +++ b/AmperfyKit/Player/BackendAudioPlayer.swift @@ -429,7 +429,13 @@ class BackendAudioPlayer: NSObject { currentReplayGainValue = playable.replayGainTrackGain applyReplayGain() insertCachedPlayable(playable: playable) - isPlaying = shouldPlaybackStart + if shouldPlaybackStart { + isPlaying = true + } else { + // Immediately pause to prevent any audio from playing + player?.pause() + isPlaying = false + } responder?.notifyItemPreparationFinished() } else if !isOfflineMode { currentPlayUrl = "" @@ -462,7 +468,13 @@ class BackendAudioPlayer: NSObject { currentReplayGainValue = playable.replayGainTrackGain applyReplayGain() try await insertStreamPlayable(playable: playable) - isPlaying = shouldPlaybackStart + if self.shouldPlaybackStart { + self.isPlaying = true + } else { + // Immediately pause to prevent any audio from playing + self.player?.pause() + self.isPlaying = false + } if self.isAutoCachePlayedItems, !playable.isRadio, let accountInfo = playable.account?.info { self.getPlayableDownloaderCB(accountInfo).download(object: playable) From 0e6a29b29bad66cf96f5345a596244ccf2aff070 Mon Sep 17 00:00:00 2001 From: DCM4711 Date: Sat, 24 Jan 2026 21:51:12 +0100 Subject: [PATCH 009/102] Change color to black for Play and Shuffle buttons --- .../View/LibraryElementDetailTableHeaderView.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Amperfy/Screens/View/LibraryElementDetailTableHeaderView.swift b/Amperfy/Screens/View/LibraryElementDetailTableHeaderView.swift index fcd65262..fd1cf657 100644 --- a/Amperfy/Screens/View/LibraryElementDetailTableHeaderView.swift +++ b/Amperfy/Screens/View/LibraryElementDetailTableHeaderView.swift @@ -162,6 +162,18 @@ class LibraryElementDetailTableHeaderView: UIView { ) playShuffledButton.layer.cornerRadius = 10.0 playShuffledButton.isHidden = configuration.isShuffleHidden + + // Set dynamic background color: 100% black in dark mode, default grey in light mode + let buttonBackgroundColor = UIColor { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + return UIColor.black + } else { + return UIColor.secondarySystemFill + } + } + playAllButton.backgroundColor = buttonBackgroundColor + playShuffledButton.backgroundColor = buttonBackgroundColor + activate() registerForTraitChanges( [UITraitUserInterfaceStyle.self, UITraitHorizontalSizeClass.self], From 296a607dba146bbe5afd164ea051c3ccc3768f9b Mon Sep 17 00:00:00 2001 From: DCM4711 Date: Sat, 24 Jan 2026 21:58:10 +0100 Subject: [PATCH 010/102] REAL FIX for small bug where music is briefly heard when clicking >> or << while music is paused --- AmperfyKit/Player/BackendAudioPlayer.swift | 43 ++++++++++------------ 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/AmperfyKit/Player/BackendAudioPlayer.swift b/AmperfyKit/Player/BackendAudioPlayer.swift index 47c6f14f..aba9df95 100644 --- a/AmperfyKit/Player/BackendAudioPlayer.swift +++ b/AmperfyKit/Player/BackendAudioPlayer.swift @@ -428,14 +428,8 @@ class BackendAudioPlayer: NSObject { } currentReplayGainValue = playable.replayGainTrackGain applyReplayGain() - insertCachedPlayable(playable: playable) - if shouldPlaybackStart { - isPlaying = true - } else { - // Immediately pause to prevent any audio from playing - player?.pause() - isPlaying = false - } + insertCachedPlayable(playable: playable, autoStartPlayback: shouldPlaybackStart) + isPlaying = shouldPlaybackStart responder?.notifyItemPreparationFinished() } else if !isOfflineMode { currentPlayUrl = "" @@ -467,14 +461,8 @@ class BackendAudioPlayer: NSObject { do { currentReplayGainValue = playable.replayGainTrackGain applyReplayGain() - try await insertStreamPlayable(playable: playable) - if self.shouldPlaybackStart { - self.isPlaying = true - } else { - // Immediately pause to prevent any audio from playing - self.player?.pause() - self.isPlaying = false - } + try await insertStreamPlayable(playable: playable, autoStartPlayback: self.shouldPlaybackStart) + self.isPlaying = self.shouldPlaybackStart if self.isAutoCachePlayedItems, !playable.isRadio, let accountInfo = playable.account?.info { self.getPlayableDownloaderCB(accountInfo).download(object: playable) @@ -537,7 +525,8 @@ class BackendAudioPlayer: NSObject { private func insertCachedPlayable( playable: AbstractPlayable, - queueType: BackendAudioQueueType = .play + queueType: BackendAudioQueueType = .play, + autoStartPlayback: Bool = true ) { guard let fileURL = cacheProxy.getFileURL(forPlayable: playable) else { return @@ -551,13 +540,14 @@ class BackendAudioPlayer: NSObject { os_log(.default, "Insert Cache: %s (%s)", playable.displayString, fileURL.absoluteString) } if playable.isSong { userStatistics.playedSong(isPlayedFromCache: true) } - insert(playable: playable, withUrl: fileURL, queueType: queueType) + insert(playable: playable, withUrl: fileURL, queueType: queueType, autoStartPlayback: autoStartPlayback) } @MainActor private func insertStreamPlayable( playable: AbstractPlayable, - queueType: BackendAudioQueueType = .play + queueType: BackendAudioQueueType = .play, + autoStartPlayback: Bool = true ) async throws { let streamingMaxBitrate = streamingMaxBitrates.getActive(networkMonitor: networkMonitor) let streamingTranscodingFormat = streamingTranscodings.getActive(networkMonitor: networkMonitor) @@ -616,7 +606,8 @@ class BackendAudioPlayer: NSObject { playable: playable, withUrl: streamUrl, streamingMaxBitrate: streamingMaxBitrate, - queueType: queueType + queueType: queueType, + autoStartPlayback: autoStartPlayback ) } @@ -624,7 +615,8 @@ class BackendAudioPlayer: NSObject { playable: AbstractPlayable, withUrl url: URL, streamingMaxBitrate: StreamingMaxBitratePreference = .noLimit, - queueType: BackendAudioQueueType + queueType: BackendAudioQueueType, + autoStartPlayback: Bool = true ) { if queueType == .play { seekTimeWhenStarted = nil @@ -638,12 +630,13 @@ class BackendAudioPlayer: NSObject { } else { asset = AVURLAsset(url: url) } - playInPlayer(asset: asset, queueType: queueType) + playInPlayer(asset: asset, queueType: queueType, autoStartPlayback: autoStartPlayback) } private func playInPlayer( asset: AVURLAsset?, - queueType: BackendAudioQueueType + queueType: BackendAudioQueueType, + autoStartPlayback: Bool = true ) { guard let asset = asset else { clearPlayer() @@ -654,6 +647,10 @@ class BackendAudioPlayer: NSObject { case .play: currentPreparedUrl = asset.url.absoluteString player?.play(url: asset.url) + // Immediately pause if we don't want to auto-start playback + if !autoStartPlayback { + player?.pause() + } case .queue: nextPreloadedUrl = asset.url.absoluteString player?.queue(url: asset.url) From ae2c8c9cf123e7bf8e2ce4881e7c3e11b0e290f2 Mon Sep 17 00:00:00 2001 From: DCM4711 Date: Sat, 24 Jan 2026 23:11:58 +0100 Subject: [PATCH 011/102] Fix to light unselected rating stars in Light theme --- Amperfy/Screens/LaunchScreen.storyboard | 7 ++- Amperfy/Screens/View/RatingView.swift | 10 +++- .../AmperfyAppIcon.icon/Assets/Logo.png | Bin 50118 -> 0 bytes .../AmperfyAppIcon.icon/Assets/icon.png | Bin 0 -> 182669 bytes .../Assets/AmperfyAppIcon.icon/icon.json | 54 +++++++++++++++--- 5 files changed, 57 insertions(+), 14 deletions(-) delete mode 100644 AmperfyKit/Assets/AmperfyAppIcon.icon/Assets/Logo.png create mode 100644 AmperfyKit/Assets/AmperfyAppIcon.icon/Assets/icon.png diff --git a/Amperfy/Screens/LaunchScreen.storyboard b/Amperfy/Screens/LaunchScreen.storyboard index 764c5351..e21bd184 100644 --- a/Amperfy/Screens/LaunchScreen.storyboard +++ b/Amperfy/Screens/LaunchScreen.storyboard @@ -1,8 +1,8 @@ - + - + @@ -19,9 +19,10 @@ -