diff --git a/Amphlify.xcodeproj/project.pbxproj b/Amphlify.xcodeproj/project.pbxproj index fec31cd..dc8517e 100644 --- a/Amphlify.xcodeproj/project.pbxproj +++ b/Amphlify.xcodeproj/project.pbxproj @@ -167,7 +167,10 @@ GCC_OPTIMIZATION_LEVEL = 0; IPHONEOS_DEPLOYMENT_TARGET = 26.0; ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; + MACOSX_DEPLOYMENT_TARGET = 26.0; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -186,7 +189,10 @@ ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; - SDKROOT = iphoneos; + MACOSX_DEPLOYMENT_TARGET = 26.0; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; }; @@ -206,10 +212,17 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - LD_RUNPATH_SEARCH_PATHS = ( + "LD_RUNPATH_SEARCH_PATHS[sdk=iphone*]" = ( "$(inherited)", "@executable_path/Frameworks", ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + CODE_SIGN_ENTITLEMENTS = Config/Amphlify.entitlements; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; MARKETING_VERSION = 0.1; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).amphlify"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -217,7 +230,7 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -235,10 +248,17 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - LD_RUNPATH_SEARCH_PATHS = ( + "LD_RUNPATH_SEARCH_PATHS[sdk=iphone*]" = ( "$(inherited)", "@executable_path/Frameworks", ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + CODE_SIGN_ENTITLEMENTS = Config/Amphlify.entitlements; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; MARKETING_VERSION = 0.1; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).amphlify"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -246,7 +266,7 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; diff --git a/Amphlify/App/AmphlifyApp.swift b/Amphlify/App/AmphlifyApp.swift index c099714..13a1b8c 100644 --- a/Amphlify/App/AmphlifyApp.swift +++ b/Amphlify/App/AmphlifyApp.swift @@ -27,5 +27,15 @@ struct AmphlifyApp: App { } #endif } + #if os(macOS) + .defaultSize(width: 1280, height: 800) + .commands { MacCommands(app: app) } + #endif + #if os(macOS) + Settings { + MacSettingsView() + .environment(app) + } + #endif } } diff --git a/Amphlify/App/AppModel.swift b/Amphlify/App/AppModel.swift index 99d432c..dd8010c 100644 --- a/Amphlify/App/AppModel.swift +++ b/Amphlify/App/AppModel.swift @@ -16,6 +16,7 @@ final class AppModel { let library: any Library let player: PlayerModel let artwork: ArtworkLoader + let settings: SettingsStore let router = Router() private(set) var sources: [any LibrarySource] private(set) var revision = 0 @@ -23,6 +24,7 @@ final class AppModel { private let registry: SourceRegistry private let accounts: AccountStore private let connect: Connector + private let tuning: (any PlaybackTuning)? private let sync: SyncEngine private let remote = RemoteCommands() private var isSyncing = false @@ -32,11 +34,15 @@ final class AppModel { registry: SourceRegistry, accounts: AccountStore, session: PlaybackSession, + settings: SettingsStore, + tuning: (any PlaybackTuning)?, connect: @escaping Connector ) { self.library = library self.registry = registry self.accounts = accounts + self.settings = settings + self.tuning = tuning self.connect = connect sources = registry.sources sync = SyncEngine(library: library) @@ -56,6 +62,7 @@ final class AppModel { isSyncing = true player.start() remote.start(player: player, artwork: artwork) + async let tuned: () = applyTuning() let updates = await library.updates() await syncAll() #if DEBUG @@ -67,6 +74,14 @@ final class AppModel { for await _ in updates { await refresh() } + await tuned + } + + private func applyTuning() async { + guard let tuning else { return } + for await isOn in Observations({ self.settings.playback.matchSampleRate }) { + await tuning.setMatchesSampleRate(isOn) + } } func resync() async { diff --git a/Amphlify/App/ArtworkLoader.swift b/Amphlify/App/ArtworkLoader.swift index f80c925..d6c4cd5 100644 --- a/Amphlify/App/ArtworkLoader.swift +++ b/Amphlify/App/ArtworkLoader.swift @@ -1,8 +1,8 @@ import Domain -import UIKit +import Foundation final class ArtworkLoader { - private let cache = NSCache() + private let cache = NSCache() private let registry: SourceRegistry init(registry: SourceRegistry) { @@ -10,11 +10,11 @@ final class ArtworkLoader { cache.countLimit = 400 } - func cached(_ id: EntityID, pixelSize: Int) -> UIImage? { + func cached(_ id: EntityID, pixelSize: Int) -> PlatformImage? { cache.object(forKey: key(id, pixelSize)) } - func image(_ id: EntityID, pixelSize: Int) async -> UIImage? { + func image(_ id: EntityID, pixelSize: Int) async -> PlatformImage? { if let hit = cached(id, pixelSize: pixelSize) { return hit } @@ -31,7 +31,7 @@ final class ArtworkLoader { } @concurrent - private nonisolated static func load(_ request: ResourceRequest) async -> UIImage? { + private nonisolated static func load(_ request: ResourceRequest) async -> PlatformImage? { let data: Data? if request.url.isFileURL { data = try? Data(contentsOf: request.url) @@ -40,6 +40,6 @@ final class ArtworkLoader { request.headers.forEach { urlRequest.setValue($1, forHTTPHeaderField: $0) } data = try? await URLSession.shared.data(for: urlRequest).0 } - return data.flatMap(UIImage.init(data:))?.preparingForDisplay() + return data.flatMap(PlatformImage.decoded) } } diff --git a/Amphlify/App/Composition.swift b/Amphlify/App/Composition.swift index b3a6146..25186e9 100644 --- a/Amphlify/App/Composition.swift +++ b/Amphlify/App/Composition.swift @@ -29,6 +29,8 @@ enum Composition { registry: registry, accounts: accounts, session: PlaybackSession(engine: engine), + settings: SettingsStore(), + tuning: engine, connect: connect ) } @@ -65,6 +67,8 @@ enum Composition { registry: registry, accounts: AccountStore(), session: PlaybackSession(engine: SilentEngine()), + settings: SettingsStore(), + tuning: nil, connect: connect ) } diff --git a/Amphlify/App/DemoScene.swift b/Amphlify/App/DemoScene.swift index cf4b6ba..7a5d604 100644 --- a/Amphlify/App/DemoScene.swift +++ b/Amphlify/App/DemoScene.swift @@ -56,7 +56,11 @@ private func playAlbum(_ album: Album, in app: AppModel) async { let router = app.router show(.album(album.id), in: router) - await app.player.play(app.library.tracks(inAlbum: album.id), startingAt: 2) + if self == .queue { + await app.player.play(Array(app.library.tracks().prefix(40)), startingAt: 14) + } else { + await app.player.play(app.library.tracks(inAlbum: album.id), startingAt: 2) + } try? await Task.sleep(for: .seconds(1)) router.showsNowPlaying = true app.player.send(.seek(to: 47)) diff --git a/Amphlify/App/PlayerModel.swift b/Amphlify/App/PlayerModel.swift index 2f02d22..ccc03e1 100644 --- a/Amphlify/App/PlayerModel.swift +++ b/Amphlify/App/PlayerModel.swift @@ -50,6 +50,27 @@ final class PlayerModel { Task { await session.send(command) } } + #if os(macOS) + private var audibleVolume = 1.0 + + func setVolume(_ volume: Double) { + guard volume.isFinite else { return } + if volume > 0 { + audibleVolume = min(volume, 1) + } + send(.setVolume(volume)) + } + + func toggleMute() { + setVolume(state.volume > 0 ? 0 : audibleVolume) + } + #endif + + func seek(to position: TimeInterval) async { + await session.send(.seek(to: position)) + state = await session.snapshot + } + func play(_ tracks: [Track], startingAt index: Int = 0) { send(.setQueue(tracks: tracks, startIndex: index, shuffled: false)) } diff --git a/Amphlify/App/Router.swift b/Amphlify/App/Router.swift index 0438ab4..886b421 100644 --- a/Amphlify/App/Router.swift +++ b/Amphlify/App/Router.swift @@ -18,9 +18,14 @@ enum Route: Hashable { case section(LibrarySection) } +enum SidePanel: Hashable { + case lyrics, queue +} + @Observable final class Router { var tab: AppTab = .home + var sidePanel: SidePanel? var paths: [AppTab: [Route]] = [:] var showsNowPlaying = false var showsQueue = false { @@ -39,6 +44,8 @@ final class Router { } } + var searchRequests = 0 + func path(for tab: AppTab) -> [Route] { paths[tab] ?? [] } @@ -47,4 +54,17 @@ final class Router { showsNowPlaying = false paths[tab, default: []].append(route) } + + @discardableResult + func back() -> Bool { + guard !path(for: tab).isEmpty else { return false } + paths[tab]?.removeLast() + return true + } + + func find() { + showsNowPlaying = false + tab = .search + searchRequests += 1 + } } diff --git a/Amphlify/App/Settings.swift b/Amphlify/App/Settings.swift new file mode 100644 index 0000000..e00c6c9 --- /dev/null +++ b/Amphlify/App/Settings.swift @@ -0,0 +1,62 @@ +import Foundation +import Observation + +struct PlaybackSettings: Codable, Hashable { + var matchSampleRate = false + var matchBitDepth = false + + init() {} + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + matchSampleRate = try container.decodeIfPresent(Bool.self, forKey: .matchSampleRate) ?? false + matchBitDepth = try container.decodeIfPresent(Bool.self, forKey: .matchBitDepth) ?? false + } +} + +struct AppSettings: Codable, Hashable { + var playback = PlaybackSettings() + + init(playback: PlaybackSettings = PlaybackSettings()) { + self.playback = playback + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + playback = try container.decodeIfPresent(PlaybackSettings.self, forKey: .playback) ?? PlaybackSettings() + } +} + +@Observable +final class SettingsStore { + var playback: PlaybackSettings { + didSet { persist() } + } + + private let file: URL + + init() { + let support = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let folder = (support ?? URL.temporaryDirectory).appending(path: "Amphlify", directoryHint: .isDirectory) + try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + file = folder.appending(path: "settings.json") + let stored = (try? Data(contentsOf: file)).flatMap { try? JSONDecoder().decode(AppSettings.self, from: $0) } + playback = (stored ?? AppSettings()).playback + #if DEBUG + if UserDefaults.standard.object(forKey: "matchSampleRate") != nil { + playback.matchSampleRate = UserDefaults.standard.bool(forKey: "matchSampleRate") + } + #endif + } + + private func persist() { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try? encoder.encode(AppSettings(playback: playback)).write(to: file, options: .atomic) + } +} diff --git a/Amphlify/Resources/Assets.xcassets/Home.imageset/Contents.json b/Amphlify/Resources/Assets.xcassets/Home.imageset/Contents.json new file mode 100644 index 0000000..f9f52d7 --- /dev/null +++ b/Amphlify/Resources/Assets.xcassets/Home.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Home.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Amphlify/Resources/Assets.xcassets/Home.imageset/Home.pdf b/Amphlify/Resources/Assets.xcassets/Home.imageset/Home.pdf new file mode 100644 index 0000000..1671da5 Binary files /dev/null and b/Amphlify/Resources/Assets.xcassets/Home.imageset/Home.pdf differ diff --git a/Amphlify/Resources/Localizable.xcstrings b/Amphlify/Resources/Localizable.xcstrings index b67e57c..6f9bd9b 100644 --- a/Amphlify/Resources/Localizable.xcstrings +++ b/Amphlify/Resources/Localizable.xcstrings @@ -1,6 +1,16 @@ { "sourceLanguage": "en", "strings": { + "%@ kHz": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ кГц" + } + } + } + }, "%lld songs, %@": { "localizations": { "en": { @@ -63,6 +73,26 @@ } } }, + "Add": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить" + } + } + } + }, + "AirPlay": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "AirPlay" + } + } + } + }, "Album · %@": { "localizations": { "ru": { @@ -83,6 +113,16 @@ } } }, + "All Playlists": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Все плейлисты" + } + } + } + }, "Artist": { "localizations": { "ru": { @@ -123,6 +163,26 @@ } } }, + "AutoMix": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "AutoMix" + } + } + } + }, + "AutoPlay": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автовоспроизведение" + } + } + } + }, "Browse Categories": { "localizations": { "ru": { @@ -153,6 +213,16 @@ } } }, + "Close": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть" + } + } + } + }, "Done": { "localizations": { "ru": { @@ -183,6 +253,16 @@ } } }, + "From %@": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Источник: %@" + } + } + } + }, "Genres": { "localizations": { "ru": { @@ -243,6 +323,16 @@ } } }, + "Match sample rate to the song": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подстраивать частоту дискретизации под песню" + } + } + } + }, "More": { "localizations": { "ru": { @@ -273,6 +363,16 @@ } } }, + "New": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новое" + } + } + } + }, "Next": { "localizations": { "ru": { @@ -283,6 +383,16 @@ } } }, + "No Output Device": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет устройства вывода" + } + } + } + }, "Not Playing": { "localizations": { "ru": { @@ -313,6 +423,16 @@ } } }, + "Output Device": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Устройство вывода" + } + } + } + }, "Password": { "localizations": { "ru": { @@ -333,6 +453,16 @@ } } }, + "Picture in Picture": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Картинка в картинке" + } + } + } + }, "Play": { "localizations": { "ru": { @@ -363,6 +493,16 @@ } } }, + "Playback": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Воспроизведение" + } + } + } + }, "Playing Next": { "localizations": { "ru": { @@ -443,6 +583,16 @@ } } }, + "Remove from queue": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить из очереди" + } + } + } + }, "Repeat": { "localizations": { "ru": { @@ -453,6 +603,16 @@ } } }, + "Sample Rate": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Частота дискретизации" + } + } + } + }, "Search": { "localizations": { "ru": { @@ -473,6 +633,16 @@ } } }, + "Share": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поделиться" + } + } + } + }, "Shuffle": { "localizations": { "ru": { @@ -553,6 +723,16 @@ } } }, + "The output device's sample rate is changed between songs to match each song. Some interfaces, such as UAD, may not like this.": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Частота дискретизации устройства вывода будет меняться между песнями под каждую песню. Некоторые аудиоинтерфейсы (например, UAD) могут плохо это переносить." + } + } + } + }, "Title": { "localizations": { "ru": { @@ -643,6 +823,16 @@ } } }, + "You Might Also Like": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вам может понравиться" + } + } + } + }, "Your Playlists": { "localizations": { "ru": { @@ -652,6 +842,296 @@ } } } + }, + "Mute": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mute" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выключить звук" + } + } + } + }, + "Unmute": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unmute" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить звук" + } + } + } + }, + "Clear": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить" + } + } + } + }, + "Continue Playing": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "На очереди" + } + } + } + }, + "There’s no music in the queue.": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "В очереди ничего нет." + } + } + } + }, + "Latest Release": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Последний релиз" + } + } + } + }, + "%lld songs": { + "localizations": { + "en": { + "variations": { + "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld song" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld songs" } } + } + } + }, + "ru": { + "variations": { + "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld песня" } }, + "few": { "stringUnit": { "state": "translated", "value": "%lld песни" } }, + "many": { "stringUnit": { "state": "translated", "value": "%lld песен" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld песни" } } + } + } + } + } + }, + "Find": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Найти" + } + } + } + }, + "Show Playing Next": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать список «На очереди»" + } + } + } + }, + "Hide Playing Next": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скрыть список «На очереди»" + } + } + } + }, + "Show Lyrics": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать текст песни" + } + } + } + }, + "Hide Lyrics": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скрыть текст песни" + } + } + } + }, + "Controls": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управление" + } + } + } + }, + "Stop": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Стоп" + } + } + } + }, + "Next Track": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Следующая дорожка" + } + } + } + }, + "Previous Track": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предыдущая дорожка" + } + } + } + }, + "Go to Current Song": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перейти к текущей песне" + } + } + } + }, + "Increase Volume": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Увеличить громкость" + } + } + } + }, + "Set Volume to Maximum": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Установить громкость на максимум" + } + } + } + }, + "Decrease Volume": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уменьшить громкость" + } + } + } + }, + "Set Volume to Minimum": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Установить громкость на минимум" + } + } + } + }, + "Back": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Назад" + } + } + } + }, + "Top Results": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Лучшие результаты" + } + } + } + }, + "Recently Searched": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Недавние поиски" + } + } + } + }, + "Song · %@": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "Песня · %@" + } + } + } + }, + "History": { + "localizations": { + "ru": { + "stringUnit": { + "state": "translated", + "value": "История" + } + } + } } }, "version": "1.0" diff --git a/Amphlify/UI/Components/ArtworkView.swift b/Amphlify/UI/Components/ArtworkView.swift index d626282..e0b977b 100644 --- a/Amphlify/UI/Components/ArtworkView.swift +++ b/Amphlify/UI/Components/ArtworkView.swift @@ -12,17 +12,17 @@ struct ArtworkView: View { var pixelSize = 320 @Environment(AppModel.self) private var app - @State private var image: UIImage? + @State private var image: PlatformImage? var body: some View { - Color(.secondarySystemFill) + Color.secondary.opacity(0.15) .aspectRatio(1, contentMode: .fit) .overlay { if let image = image ?? artwork.flatMap({ app.artwork.cached($0, pixelSize: pixelSize) }) { - Image(uiImage: image).resizable().scaledToFill() + Image(platform: image).resizable().scaledToFill() } else { GeometryReader { proxy in - Image(systemName: "music.note") + Image(systemName: placeholder) .font(.system(size: proxy.size.width * 0.4)) .foregroundStyle(.tertiary) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -39,6 +39,13 @@ struct ArtworkView: View { .accessibilityHidden(true) } + private var placeholder: String { + if case .circle = shape { + return "music.microphone" + } + return "music.note" + } + private var clip: RoundedRectangle { switch shape { case let .rounded(radius): RoundedRectangle(cornerRadius: radius, style: .continuous) diff --git a/Amphlify/UI/Components/Chrome.swift b/Amphlify/UI/Components/Chrome.swift index 13df034..3f140ea 100644 --- a/Amphlify/UI/Components/Chrome.swift +++ b/Amphlify/UI/Components/Chrome.swift @@ -1,15 +1,26 @@ import SwiftUI +#if canImport(UIKit) + import UIKit +#endif enum Metrics { - static let margin: CGFloat = UIScreen.main.bounds.width >= 414 ? 20 : 16 + #if os(iOS) + static let margin: CGFloat = UIScreen.main.bounds.width >= 414 ? 20 : 16 + #else + static let margin: CGFloat = 28 + #endif static let tileSpacing: CGFloat = 12 } extension View { - func shelfTile(fraction: CGFloat = 1 / 2.15) -> some View { - containerRelativeFrame(.horizontal) { width, _ in - (width - Metrics.margin * 2 - Metrics.tileSpacing) * fraction - } + @ViewBuilder func shelfTile(fraction: CGFloat = 1 / 2.15) -> some View { + #if os(macOS) + frame(width: fraction > 0.6 ? 320 : 128) + #else + containerRelativeFrame(.horizontal) { width, _ in + (width - Metrics.margin * 2 - Metrics.tileSpacing) * fraction + } + #endif } } @@ -18,16 +29,24 @@ struct AccountToolbar: ViewModifier { @Environment(AppModel.self) private var app func body(content: Content) -> some View { + #if os(macOS) + content + #else + iOSBody(content) + #endif + } + + private func iOSBody(_ content: Content) -> some View { content .toolbarTitleDisplayMode(.inlineLarge) .toolbar { - ToolbarItem(placement: .topBarTrailing) { + ToolbarItem(placement: .primaryAction) { Button { showsAccount = true } label: { Text(app.sources.first?.name.prefix(1).uppercased() ?? "") .font(.headline) .foregroundStyle(.white) .frame(width: 36, height: 36) - .background(Color(.systemIndigo).mix(with: .black, by: 0.45).gradient, in: .circle) + .background(Color.indigo.mix(with: .black, by: 0.45).gradient, in: .circle) } .accessibilityLabel("Account") } @@ -96,3 +115,33 @@ struct Marquee: View { Text(text).font(font).lineLimit(1).fixedSize() } } + +extension View { + func pageTitle(_ title: LocalizedStringKey) -> some View { + modifier(PageTitle(title: Text(title))) + } + + func pageTitle(verbatim title: String) -> some View { + modifier(PageTitle(title: Text(verbatim: title))) + } +} + +private struct PageTitle: ViewModifier { + let title: Text + + func body(content: Content) -> some View { + #if os(macOS) + content + .safeAreaBar(edge: .top) { + title + .font(.system(size: 26, weight: .bold)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Metrics.margin) + .padding(.bottom, 8) + } + #else + content.navigationTitle(title) + #endif + } +} diff --git a/Amphlify/UI/Components/CollectionHeader.swift b/Amphlify/UI/Components/CollectionHeader.swift index a2b33df..50fa7cf 100644 --- a/Amphlify/UI/Components/CollectionHeader.swift +++ b/Amphlify/UI/Components/CollectionHeader.swift @@ -11,6 +11,22 @@ struct CollectionHeader: View { let onShuffle: () -> Void var body: some View { + #if os(macOS) + MacCollectionHeader( + artwork: artwork, + title: title, + subtitle: subtitle, + subtitleRoute: subtitleRoute, + meta: meta, + onPlay: onPlay, + onShuffle: onShuffle + ) + #else + stacked + #endif + } + + private var stacked: some View { VStack(spacing: 0) { ArtworkView(artwork: artwork, shape: .rounded(10), pixelSize: 1024) .frame(maxWidth: 270) @@ -77,11 +93,21 @@ struct ScrollRevealedTitle: ViewModifier { .onScrollGeometryChange(for: Bool.self) { $0.contentOffset.y + $0.contentInsets.top > threshold } action: { _, passed in withAnimation(.easeOut(duration: 0.2)) { isRevealed = passed } } - .navigationBarTitleDisplayMode(.inline) + .toolbarTitleDisplayMode(.inline) + #if os(macOS) + .toolbar(removing: .title) + .toolbar { + ToolbarItem(placement: .principal) { + Text(title).font(.headline).lineLimit(1).opacity(isRevealed ? 1 : 0) + } + .sharedBackgroundVisibility(.hidden) + } + #else .toolbar { ToolbarItem(placement: .principal) { Text(title).font(.headline).lineLimit(1).opacity(isRevealed ? 1 : 0) } } + #endif } } diff --git a/Amphlify/UI/Components/PlatformImage.swift b/Amphlify/UI/Components/PlatformImage.swift new file mode 100644 index 0000000..ac8da12 --- /dev/null +++ b/Amphlify/UI/Components/PlatformImage.swift @@ -0,0 +1,65 @@ +import SwiftUI + +#if canImport(UIKit) + import UIKit + + typealias PlatformImage = UIImage + + extension PlatformImage { + nonisolated static func decoded(_ data: Data) -> PlatformImage? { + UIImage(data: data)?.preparingForDisplay() + } + + var bitmap: CGImage? { + cgImage + } + } +#else + import AppKit + + typealias PlatformImage = NSImage + + extension PlatformImage { + nonisolated static func decoded(_ data: Data) -> PlatformImage? { + NSImage(data: data) + } + + var bitmap: CGImage? { + cgImage(forProposedRect: nil, context: nil, hints: nil) + } + } +#endif + +extension Image { + init(platform image: PlatformImage) { + #if canImport(UIKit) + self.init(uiImage: image) + #else + self.init(nsImage: image) + #endif + } +} + +extension Color { + #if canImport(UIKit) + static let systemBackground = Color(.systemBackground) + static let secondarySystemBackground = Color(.secondarySystemBackground) + #else + static let systemBackground = Color(nsColor: .windowBackgroundColor) + static let secondarySystemBackground = Color(nsColor: NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + ? NSColor(srgbRed: 0x25 / 255, green: 0x23 / 255, blue: 0x2D / 255, alpha: 1) + : NSColor(srgbRed: 0xF2 / 255, green: 0xF2 / 255, blue: 0xF7 / 255, alpha: 1) + }) + #endif +} + +extension View { + func plainTextInput() -> some View { + #if os(iOS) + textInputAutocapitalization(.never).autocorrectionDisabled() + #else + autocorrectionDisabled() + #endif + } +} diff --git a/Amphlify/UI/Components/PressHighlight.swift b/Amphlify/UI/Components/PressHighlight.swift new file mode 100644 index 0000000..9e17e24 --- /dev/null +++ b/Amphlify/UI/Components/PressHighlight.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct PressHighlight: ViewModifier { + let isPressed: Bool + let fill: Color + var diameter: CGFloat? + @State private var highlighted = false + @State private var pressedAt = Date.distantPast + + func body(content: Content) -> some View { + content + .background { + Circle().fill(fill).frame(width: diameter, height: diameter) + .opacity(highlighted ? 1 : 0) + .animation(.easeOut(duration: highlighted ? 0.06 : 0.25), value: highlighted) + } + .task(id: isPressed) { + if isPressed { + pressedAt = Date() + highlighted = true + } else { + try? await Task.sleep(for: .seconds(max(0.18 - Date().timeIntervalSince(pressedAt), 0))) + guard !Task.isCancelled else { return } + highlighted = false + } + } + } +} diff --git a/Amphlify/UI/Components/Shelf.swift b/Amphlify/UI/Components/Shelf.swift index 7008f02..5b621d2 100644 --- a/Amphlify/UI/Components/Shelf.swift +++ b/Amphlify/UI/Components/Shelf.swift @@ -3,17 +3,18 @@ import SwiftUI struct Shelf: View { let title: LocalizedStringKey var route: Route? + var spacing = Metrics.tileSpacing @ViewBuilder let content: Content var body: some View { VStack(alignment: .leading, spacing: 12) { SectionHeader(title: title, route: route) ScrollView(.horizontal) { - LazyHStack(alignment: .top, spacing: Metrics.tileSpacing) { content } + LazyHStack(alignment: .top, spacing: spacing) { content } .scrollTargetLayout() } .scrollTargetBehavior(.viewAligned) - .scrollIndicators(.hidden) + .scrollIndicators(.never) .contentMargins(.horizontal, Metrics.margin, for: .scrollContent) } } @@ -39,8 +40,16 @@ struct SectionHeader: View { Text(title) } } - .font(.title2.bold()) + .font(headerFont) .padding(.horizontal, Metrics.margin) .accessibilityAddTraits(.isHeader) } + + private var headerFont: Font { + #if os(macOS) + .system(size: 15, weight: .bold) + #else + .title2.bold() + #endif + } } diff --git a/Amphlify/UI/Components/Tiles.swift b/Amphlify/UI/Components/Tiles.swift index 8ed4402..bbf5251 100644 --- a/Amphlify/UI/Components/Tiles.swift +++ b/Amphlify/UI/Components/Tiles.swift @@ -60,9 +60,17 @@ struct AlbumGrid: View { var showsArtist = true var body: some View { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: Metrics.tileSpacing, alignment: .top)], spacing: 24) { + LazyVGrid(columns: [GridItem(.adaptive(minimum: minimum), spacing: Metrics.tileSpacing, alignment: .top)], spacing: 24) { ForEach(albums) { AlbumTile(album: $0, showsArtist: showsArtist) } } .padding(.horizontal, Metrics.margin) } + + private var minimum: CGFloat { + #if os(macOS) + 160 + #else + 150 + #endif + } } diff --git a/Amphlify/UI/Components/TrackRow.swift b/Amphlify/UI/Components/TrackRow.swift index a4848a1..bcd679a 100644 --- a/Amphlify/UI/Components/TrackRow.swift +++ b/Amphlify/UI/Components/TrackRow.swift @@ -20,6 +20,14 @@ struct TrackRow: View { @Environment(PlayerModel.self) private var player var body: some View { + #if os(macOS) + MacTrackRow(track: track, leading: leading, subtitle: subtitle, action: action) + #else + row + #endif + } + + private var row: some View { HStack(spacing: 12) { Button(action: action) { HStack(spacing: 12) { @@ -144,8 +152,32 @@ struct TrackList: View { LazyVStack(spacing: 0) { ForEach(Array(tracks.enumerated()), id: \.offset) { index, track in TrackRow(track: track, leading: leading, subtitle: subtitle) { player.play(tracks, startingAt: index) } - Divider().padding(.leading, Metrics.margin + (leading == .artwork ? 60 : 40)) + Divider().padding(.leading, dividerInset).padding(.trailing, trailingInset) } } } + + private var dividerInset: CGFloat { + #if os(macOS) + Metrics.margin + (leading == .artwork ? 50 : 36) + #else + Metrics.margin + (leading == .artwork ? 60 : 40) + #endif + } + + private var trailingInset: CGFloat { + #if os(macOS) + Metrics.margin + #else + 0 + #endif + } +} + +struct HeaderDivider: View { + var body: some View { + #if os(iOS) + Divider().padding(.leading, Metrics.margin) + #endif + } } diff --git a/Amphlify/UI/Detail/AlbumDetailView.swift b/Amphlify/UI/Detail/AlbumDetailView.swift index d1539ce..cecddd9 100644 --- a/Amphlify/UI/Detail/AlbumDetailView.swift +++ b/Amphlify/UI/Detail/AlbumDetailView.swift @@ -9,6 +9,7 @@ struct AlbumDetailView: View { @State private var album: Album? @State private var tracks: [Track] = [] @State private var more: [Album] = [] + @State private var similar: [Album] = [] var body: some View { ScrollView { @@ -21,26 +22,44 @@ struct AlbumDetailView: View { subtitleRoute: album.artistID.map(Route.artist), meta: meta(album) ) { player.play(tracks) } onShuffle: { player.shuffle(tracks) } - Divider().padding(.leading, Metrics.margin) + HeaderDivider() TrackList(tracks: tracks, leading: .number, subtitle: .none) footer(album) - if !more.isEmpty { - Shelf(title: "More by \(album.artistName)", route: album.artistID.map(Route.artist)) { - ForEach(more) { AlbumTile(album: $0, showsArtist: false).shelfTile() } + VStack(spacing: 0) { + if !more.isEmpty { + Shelf(title: "More by \(album.artistName)", route: album.artistID.map(Route.artist)) { + ForEach(more) { AlbumTile(album: $0, showsArtist: false).shelfTile() } + } + .padding(.vertical, 24) + } + if !similar.isEmpty { + Shelf(title: "You Might Also Like") { + ForEach(similar) { AlbumTile(album: $0).shelfTile() } + } + .padding(.vertical, 24) + } + } + .background { + if !more.isEmpty || !similar.isEmpty { + Color.secondarySystemBackground.padding(.bottom, -2000) } - .padding(.vertical, 24) - .background(Color(.secondarySystemBackground)) } } } } .modifier(ScrollRevealedTitle(title: album?.title ?? "")) .toolbar { - ToolbarItem(placement: .topBarTrailing) { + ToolbarItemGroup(placement: .primaryAction) { + #if os(macOS) + ShareLink(item: album.map { "\($0.artistName) — \($0.title)" } ?? "") { + Label("Share", systemImage: "square.and.arrow.up") + } + #endif Menu("More", systemImage: "ellipsis") { Button("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") { player.send(.insertNext(tracks)) } Button("Play Last", systemImage: "text.line.last.and.arrowtriangle.forward") { player.send(.insertLast(tracks)) } } + .menuIndicator(.hidden) } } .task(id: app.revision) { @@ -49,6 +68,9 @@ struct AlbumDetailView: View { if let artist = album?.artistID { more = await app.library.albums(byArtist: artist).filter { $0.id != id } } + if let genre = album?.genre { + similar = await Array(app.library.albums(inGenre: genre).filter { $0.artistID != album?.artistID }.prefix(12)) + } } } @@ -65,6 +87,9 @@ struct AlbumDetailView: View { if let detail = album.bestFormat?.detail { Text(detail) } + if let year = album.year { + Text(verbatim: "℗ \(year) \(album.artistName)") + } } .font(.footnote) .foregroundStyle(.secondary) @@ -94,7 +119,7 @@ struct PlaylistDetailView: View { title: title, subtitle: playlist?.summary.isEmpty == false ? playlist?.summary : nil ) { player.play(tracks) } onShuffle: { player.shuffle(tracks) } - Divider().padding(.leading, Metrics.margin) + HeaderDivider() TrackList(tracks: tracks) Text("\(tracks.count) songs, \(tracks.reduce(0) { $0 + $1.duration }.spoken)") .font(.footnote) diff --git a/Amphlify/UI/Detail/ArtistDetailView.swift b/Amphlify/UI/Detail/ArtistDetailView.swift index 3674e52..70568be 100644 --- a/Amphlify/UI/Detail/ArtistDetailView.swift +++ b/Amphlify/UI/Detail/ArtistDetailView.swift @@ -11,6 +11,23 @@ struct ArtistDetailView: View { @State private var topTracks: [Track] = [] var body: some View { + #if os(macOS) + ScrollView { + MacArtistPage(id: id, artist: artist, albums: albums, tracks: topTracks) + } + .background { Backdrop(artwork: artist?.artwork) } + .modifier(ScrollRevealedTitle(title: artist?.name ?? "", threshold: 120)) + .task(id: app.revision) { + artist = await app.library.artist(id) + albums = await app.library.albums(byArtist: id) + topTracks = await Array(app.library.tracks(byArtist: id).prefix(12)) + } + #else + page + #endif + } + + private var page: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { hero @@ -29,12 +46,11 @@ struct ArtistDetailView: View { } .padding(.bottom, 24) } - .ignoresSafeArea(edges: .top) .modifier(ScrollRevealedTitle(title: artist?.name ?? "", threshold: 270)) - .scrollEdgeEffectHidden(true, for: .top) + .modifier(EdgeToEdgeTop()) .toolbar { if app.canFavorite(id) { - ToolbarItem(placement: .topBarTrailing) { + ToolbarItem(placement: .primaryAction) { let isFavorite = app.favorites.contains(id) Button( isFavorite ? "Undo Favorite" as LocalizedStringKey : "Favorite", @@ -53,6 +69,10 @@ struct ArtistDetailView: View { } private var hero: some View { + banner + } + + private var banner: some View { ArtworkView(artwork: artist?.artwork, shape: .rounded(0), pixelSize: 1024) .containerRelativeFrame(.horizontal) .visualEffect { content, proxy in @@ -63,8 +83,8 @@ struct ArtistDetailView: View { LinearGradient( stops: [ .init(color: .clear, location: 0), - .init(color: Color(.systemBackground).opacity(0.55), location: 0.55), - .init(color: Color(.systemBackground), location: 1), + .init(color: Color.systemBackground.opacity(0.55), location: 0.55), + .init(color: Color.systemBackground, location: 1), ], startPoint: .top, endPoint: .bottom @@ -94,3 +114,13 @@ struct ArtistDetailView: View { } } } + +private struct EdgeToEdgeTop: ViewModifier { + func body(content: Content) -> some View { + #if os(macOS) + content + #else + content.ignoresSafeArea(edges: .top).scrollEdgeEffectHidden(true, for: .top) + #endif + } +} diff --git a/Amphlify/UI/Home/HomeView.swift b/Amphlify/UI/Home/HomeView.swift index f585321..72805af 100644 --- a/Amphlify/UI/Home/HomeView.swift +++ b/Amphlify/UI/Home/HomeView.swift @@ -28,7 +28,7 @@ struct HomeView: View { } .padding(.vertical, 12) } - .navigationTitle("Home") + .pageTitle("Home") .modifier(AccountToolbar()) .task(id: app.revision) { await load() } } @@ -103,7 +103,7 @@ struct AccountView: View { } } .navigationTitle("Account") - .navigationBarTitleDisplayMode(.inline) + .toolbarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done", systemImage: "checkmark") { dismiss() } diff --git a/Amphlify/UI/Library/LibraryLists.swift b/Amphlify/UI/Library/LibraryLists.swift index c8b0992..69caf2a 100644 --- a/Amphlify/UI/Library/LibraryLists.swift +++ b/Amphlify/UI/Library/LibraryLists.swift @@ -11,9 +11,9 @@ struct AlbumsView: View { ScrollView { AlbumGrid(albums: albums).padding(.vertical, 12) } - .navigationTitle("Albums") + .pageTitle("Albums") .toolbar { - ToolbarItem(placement: .topBarTrailing) { + ToolbarItem(placement: .primaryAction) { Menu("Sort", systemImage: "arrow.up.arrow.down") { Picker("Sort", selection: $sort) { Text("Artist").tag(AlbumSort.artist) @@ -43,7 +43,7 @@ struct ArtistsView: View { .alignmentGuide(.listRowSeparatorLeading) { $0[.leading] + 66 } } .listStyle(.plain) - .navigationTitle("Artists") + .pageTitle("Artists") .task(id: app.revision) { artists = await app.library.artists() } } } @@ -60,7 +60,7 @@ struct SongsView: View { .padding(.vertical, 12) TrackList(tracks: tracks) } - .navigationTitle("Songs") + .pageTitle("Songs") .task(id: app.revision) { tracks = await app.library.tracks() } } } @@ -88,7 +88,7 @@ struct PlaylistsView: View { .alignmentGuide(.listRowSeparatorLeading) { $0[.leading] + 86 } } .listStyle(.plain) - .navigationTitle("Playlists") + .pageTitle("Playlists") .task(id: app.revision) { playlists = await app.library.playlists() } } @@ -109,7 +109,7 @@ struct GenresView: View { NavigationLink(value: Route.genre(genre.name)) { Text(genre.name).font(.title3) } } .listStyle(.plain) - .navigationTitle("Genres") + .pageTitle("Genres") .task(id: app.revision) { genres = await app.library.genres() } } } @@ -123,7 +123,7 @@ struct GenreDetailView: View { ScrollView { AlbumGrid(albums: albums).padding(.vertical, 12) } - .navigationTitle(genre) + .pageTitle(verbatim: genre) .task(id: app.revision) { albums = await app.library.albums(inGenre: genre) } } } diff --git a/Amphlify/UI/Mac/MacArtistPage.swift b/Amphlify/UI/Mac/MacArtistPage.swift new file mode 100644 index 0000000..026be98 --- /dev/null +++ b/Amphlify/UI/Mac/MacArtistPage.swift @@ -0,0 +1,125 @@ +#if os(macOS) + import Domain + import SwiftUI + + struct MacArtistPage: View { + let id: EntityID + let artist: Artist? + let albums: [Album] + let tracks: [Track] + + @Environment(AppModel.self) private var app + @Environment(PlayerModel.self) private var player + + var body: some View { + VStack(alignment: .leading, spacing: 36) { + header + if latest != nil || !tracks.isEmpty { + HStack(alignment: .top, spacing: 40) { + if let latest { + latestRelease(latest) + } + if !tracks.isEmpty { + topSongs + } + } + .padding(.leading, Metrics.margin) + } + if !albums.isEmpty { + Shelf(title: "Albums", spacing: 20) { + ForEach(albums) { AlbumTile(album: $0, showsArtist: false).frame(width: 170) } + } + } + } + .padding(.bottom, 96) + .environment(\.colorScheme, .dark) + } + + private var latest: Album? { + albums.max { ($0.year ?? 0, $0.addedAt) < ($1.year ?? 0, $1.addedAt) } + } + + private var header: some View { + VStack(spacing: 24) { + Text(artist?.name ?? "") + .font(.system(size: 46, weight: .bold)) + .lineLimit(2) + .multilineTextAlignment(.center) + HStack(spacing: 16) { + round("Shuffle", "shuffle") { play(shuffled: true) } + Button { play(shuffled: false) } label: { + Image(systemName: "play.fill") + .font(.system(size: 30)) + .foregroundStyle(.black) + .offset(x: 2) + .frame(width: 72, height: 72) + .background(.white, in: .circle) + .contentShape(.circle) + } + .buttonStyle(MacPlayerButtonStyle()) + .accessibilityLabel("Play") + if app.canFavorite(id) { + let isFavorite = app.favorites.contains(id) + round(isFavorite ? "Undo Favorite" : "Favorite", isFavorite ? "star.fill" : "star") { + app.toggleFavorite(id, kind: .artist) + } + } + } + } + .frame(maxWidth: .infinity) + .padding(.top, 40) + } + + private func round(_ title: LocalizedStringKey, _ symbol: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: symbol) + .font(.system(size: 17, weight: .semibold)) + .frame(width: 48, height: 48) + .background(.white.opacity(0.14), in: .circle) + .contentShape(.circle) + } + .buttonStyle(MacPlayerButtonStyle()) + .accessibilityLabel(title) + } + + private func latestRelease(_ album: Album) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("Latest Release").font(.system(size: 15, weight: .bold)) + NavigationLink(value: Route.album(album.id)) { + HStack(alignment: .center, spacing: 16) { + ArtworkView(artwork: album.artwork, pixelSize: 512).frame(width: 160, height: 160) + VStack(alignment: .leading, spacing: 2) { + if let year = album.year { + Text(verbatim: String(year)).font(.system(size: 11, weight: .semibold)).foregroundStyle(.secondary) + } + Text(album.title).font(.system(size: 15)) + Text("\(album.trackCount) songs").font(.system(size: 15)).foregroundStyle(.secondary) + } + .lineLimit(2) + .frame(width: 150, alignment: .leading) + } + .contentShape(.rect) + } + .buttonStyle(.plain) + } + } + + private var topSongs: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Top Songs").font(.system(size: 15, weight: .bold)) + MacSongGrid(tracks: tracks, subtitle: .album, leading: 0) { player.play(tracks, startingAt: $0) } + } + } + + private func play(shuffled: Bool) { + Task { + let all = await app.library.tracks(byArtist: id) + if shuffled { + player.shuffle(all) + } else { + player.play(all) + } + } + } + } +#endif diff --git a/Amphlify/UI/Mac/MacCollectionHeader.swift b/Amphlify/UI/Mac/MacCollectionHeader.swift new file mode 100644 index 0000000..d8cdea8 --- /dev/null +++ b/Amphlify/UI/Mac/MacCollectionHeader.swift @@ -0,0 +1,81 @@ +#if os(macOS) + import Domain + import SwiftUI + + struct MacCollectionHeader: View { + let artwork: EntityID? + var shape: ArtworkView.Shape = .rounded(6) + let title: String + var subtitle: String? + var subtitleRoute: Route? + var meta: String? + let onPlay: () -> Void + let onShuffle: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 32) { + ArtworkView(artwork: artwork, shape: shape, pixelSize: 1024) + .frame(width: 270, height: 270) + .shadow(color: .black.opacity(0.3), radius: 10, y: 4) + .padding(.leading, 4) + VStack(alignment: .leading, spacing: 0) { + Spacer(minLength: 0) + Text(title).font(.system(size: 26, weight: .bold)).lineLimit(2) + if let subtitle { + if let subtitleRoute { + NavigationLink(value: subtitleRoute) { Text(subtitle).font(.system(size: 24)).foregroundStyle(.tint) } + .buttonStyle(.plain) + } else { + Text(subtitle).font(.system(size: 24)).foregroundStyle(.secondary) + } + } + if let meta { + metaLine(meta).font(.system(size: 11)).foregroundStyle(.secondary).padding(.top, 4) + } + Spacer(minLength: 0) + buttons + } + .frame(height: 270) + Spacer(minLength: 0) + } + .padding(.horizontal, Metrics.margin) + .padding(.top, 8) + .padding(.bottom, 16) + } + + private func metaLine(_ meta: String) -> Text { + meta.components(separatedBy: " · ").enumerated() + .flatMap { index, part -> [Text] in + let separator: [Text] = index > 0 ? [Text(verbatim: " • ")] : [] + let glyph: [Text] = part.hasSuffix("Lossless") ? [Text(Image(systemName: "waveform"))] : [] + return separator + glyph + [Text(verbatim: part)] + } + .reduce(Text(verbatim: ""), +) + } + + private var buttons: some View { + HStack(spacing: 10) { + Button(action: onShuffle) { + Image(systemName: "shuffle") + .frame(width: 36, height: 36) + .background(.primary.opacity(0.07), in: .circle) + .contentShape(.circle) + } + .accessibilityLabel("Shuffle") + .buttonStyle(MacPlayerButtonStyle()) + Button(action: onPlay) { + Label("Play", systemImage: "play.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.tint) + .padding(.horizontal, 30) + .frame(height: 36) + .background(.primary.opacity(0.07), in: .capsule) + .contentShape(.capsule) + } + .buttonStyle(.plain) + } + .buttonStyle(.glass) + .buttonBorderShape(.capsule) + } + } +#endif diff --git a/Amphlify/UI/Mac/MacCommands.swift b/Amphlify/UI/Mac/MacCommands.swift new file mode 100644 index 0000000..1f310a8 --- /dev/null +++ b/Amphlify/UI/Mac/MacCommands.swift @@ -0,0 +1,62 @@ +#if os(macOS) + import SwiftUI + + struct MacCommands: Commands { + let app: AppModel + + private var router: Router { + app.router + } + + private var player: PlayerModel { + app.player + } + + var body: some Commands { + CommandGroup(after: .sidebar) { + Button("Find") { router.find() }.keyboardShortcut("f", modifiers: [.command, .option]) + Divider() + Button(router.sidePanel == .queue ? "Hide Playing Next" : "Show Playing Next") { toggle(.queue) } + .keyboardShortcut("u", modifiers: [.command, .option]) + Button(router.sidePanel == .lyrics ? "Hide Lyrics" : "Show Lyrics") { toggle(.lyrics) } + .keyboardShortcut("u", modifiers: [.command, .control]) + } + CommandMenu("Controls") { + Button(player.isPlaying ? "Pause" : "Play") { player.send(.togglePlayPause) } + Button("Stop") { + player.send(.pause) + player.send(.seek(to: 0)) + } + .keyboardShortcut(".", modifiers: .command) + Divider() + Button("Next Track") { player.send(.next) }.keyboardShortcut(.rightArrow, modifiers: .command) + Button("Previous Track") { player.send(.previous) }.keyboardShortcut(.leftArrow, modifiers: .command) + Divider() + Button("Go to Current Song") { + if let album = player.current?.albumID { + router.open(.album(album)) + } + } + .keyboardShortcut("l", modifiers: .command) + .disabled(player.current?.albumID == nil) + Divider() + Button("Increase Volume") { player.setVolume(min(player.state.volume + 0.1, 1)) } + .keyboardShortcut(.upArrow, modifiers: .command) + Button("Set Volume to Maximum") { player.setVolume(1) }.keyboardShortcut(.upArrow, modifiers: [.command, .shift]) + Button("Decrease Volume") { player.setVolume(max(player.state.volume - 0.1, 0)) } + .keyboardShortcut(.downArrow, modifiers: .command) + Button("Set Volume to Minimum") { player.setVolume(0) }.keyboardShortcut(.downArrow, modifiers: [.command, .shift]) + Divider() + Button("Back") { router.back() }.keyboardShortcut("[", modifiers: .command) + } + CommandGroup(after: .windowList) { + Button("Now Playing") { router.showsNowPlaying.toggle() }.keyboardShortcut("f", modifiers: [.command, .shift]) + } + } + + private func toggle(_ panel: SidePanel) { + router.showsNowPlaying = false + router.sidePanel = router.sidePanel == panel ? nil : panel + } + } +#endif diff --git a/Amphlify/UI/Mac/MacHoverArea.swift b/Amphlify/UI/Mac/MacHoverArea.swift new file mode 100644 index 0000000..90b9a63 --- /dev/null +++ b/Amphlify/UI/Mac/MacHoverArea.swift @@ -0,0 +1,41 @@ +#if os(macOS) + import AppKit + import SwiftUI + + struct MacHoverArea: NSViewRepresentable { + let onHover: (Bool) -> Void + + func makeNSView(context _: Context) -> TrackingView { + TrackingView() + } + + func updateNSView(_ view: TrackingView, context _: Context) { + view.onHover = onHover + } + + final class TrackingView: NSView { + var onHover: (Bool) -> Void = { _ in } + private var area: NSTrackingArea? + + override func hitTest(_: NSPoint) -> NSView? { + nil + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + area.map(removeTrackingArea) + let next = NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], owner: self) + addTrackingArea(next) + area = next + } + + override func mouseEntered(with _: NSEvent) { + onHover(true) + } + + override func mouseExited(with _: NSEvent) { + onHover(false) + } + } + } +#endif diff --git a/Amphlify/UI/Mac/MacNowPlayingView.swift b/Amphlify/UI/Mac/MacNowPlayingView.swift new file mode 100644 index 0000000..2a0d828 --- /dev/null +++ b/Amphlify/UI/Mac/MacNowPlayingView.swift @@ -0,0 +1,117 @@ +#if os(macOS) + import SwiftUI + + struct MacNowPlayingView: View { + @Environment(PlayerModel.self) private var player + @Environment(Router.self) private var router + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Binding var mode: SidePanel? + + var body: some View { + GeometryReader { proxy in + let columnWidth = mode == nil ? proxy.size.width : proxy.size.width / 2 + let artworkSize = min(560, columnWidth * 0.56, max(240, proxy.size.height - 260)) + HStack(spacing: 0) { + MacNowPlayingControls(artworkSize: artworkSize) + .frame(width: columnWidth) + .frame(maxHeight: .infinity) + if let mode { + panel(mode, height: proxy.size.height) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.opacity) + } + } + .animation(reduceMotion ? nil : .spring(response: 0.4, dampingFraction: 1), value: mode) + } + .overlay(alignment: .topLeading) { windowButtons } + .overlay(alignment: .topTrailing) { output } + .overlay(alignment: .bottomTrailing) { modeToggles } + .background { Backdrop(artwork: player.current?.artwork) } + .ignoresSafeArea() + .environment(\.colorScheme, .dark) + .foregroundStyle(.white) + .onExitCommand { router.showsNowPlaying = false } + .onAppear { + if let sidePanel = router.sidePanel { + mode = sidePanel + } + } + } + + @ViewBuilder private func panel(_ mode: SidePanel, height: CGFloat) -> some View { + switch mode { + case .lyrics: + LyricsView(bottomInset: 0, controls: { _ in }, seeks: true) + .environment(\.lyricsMetrics, LyricsMetrics(fontSize: 48, anchor: height * 0.36)) + .padding(.leading, 18) + .padding(.trailing, 44) + case .queue: + MacQueuePanel() + .padding(.top, 84) + .padding(.bottom, 52) + .padding(.trailing, 64) + } + } + + private var windowButtons: some View { + Button("Close", systemImage: "xmark") { router.showsNowPlaying = false } + .labelStyle(.iconOnly) + .buttonStyle(MacPlayerButtonStyle()) + .font(.system(size: 16, weight: .medium)) + .frame(width: 38, height: 36) + .glassEffect(.regular, in: .capsule) + .padding(.leading, 100) + .padding(.top, 8) + } + + private var output: some View { + HStack(spacing: 12) { + RoutePicker().frame(width: 24, height: 24).accessibilityLabel("AirPlay") + Divider().frame(height: 20) + MacVolumeControl().frame(width: 146) + } + .padding(.horizontal, 12) + .frame(height: 36) + .glassEffect(.regular, in: .capsule) + .padding(.trailing, 10) + .padding(.top, 8) + } + + private var modeToggles: some View { + HStack(spacing: 2) { + Button("Lyrics", systemImage: mode == .lyrics ? "quote.bubble.fill" : "quote.bubble") { + mode = mode == .lyrics ? nil : .lyrics + } + .modifier(MacActiveGlyph(isActive: mode == .lyrics)) + Button("Queue", systemImage: "list.bullet") { mode = mode == .queue ? nil : .queue } + .modifier(MacActiveGlyph(isActive: mode == .queue)) + } + .labelStyle(.iconOnly) + .buttonStyle(MacPlayerButtonStyle()) + .font(.system(size: 14, weight: .medium)) + .padding(4) + .glassEffect(.regular, in: .capsule) + .padding(.trailing, 10) + .padding(.bottom, 10) + } + } + + struct MacActiveGlyph: ViewModifier { + let isActive: Bool + + func body(content: Content) -> some View { + content + .foregroundStyle(.white) + .blendMode(isActive ? .destinationOut : .normal) + .frame(width: 28, height: 28) + .background { + if isActive { + Circle().fill(.white.opacity(0.85)) + } + } + .compositingGroup() + .contentShape(.circle) + .accessibilityAddTraits(isActive ? .isSelected : []) + } + } +#endif diff --git a/Amphlify/UI/Mac/MacPlayerBar.swift b/Amphlify/UI/Mac/MacPlayerBar.swift new file mode 100644 index 0000000..bac1f53 --- /dev/null +++ b/Amphlify/UI/Mac/MacPlayerBar.swift @@ -0,0 +1,221 @@ +#if os(macOS) + import Playback + import SwiftUI + + struct MacPlayerBar: View { + @Environment(PlayerModel.self) private var player + @Environment(Router.self) private var router + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var showsVolume = false + @State private var scrubbing = false + @State private var artworkHovered = false + @State private var volumeDismissal: Task? + @State private var volumeTracking = false + @State private var volumeHovered = false + + var body: some View { + HStack(spacing: 0) { + transport + metadata + .padding(.horizontal, 16) + panels + } + .padding(.horizontal, 14) + .frame(maxWidth: 700) + .frame(height: 54) + .glassEffect(.regular, in: .capsule) + .contextMenu { + if let track = player.current { + TrackMenu(track: track) + } + } + .overlay(alignment: .trailing) { + if showsVolume { + MacVolumeControl(compact: true) { tracking in + volumeTracking = tracking + volumeHover(volumeHovered) + } + .padding(.leading, 16) + .padding(.trailing, 8) + .frame(width: 150, height: 40) + .glassEffect(.regular, in: .capsule) + .padding(.trailing, 7) + .onHover { volumeHover($0) } + .transition(.scale(scale: 0.25, anchor: .trailing).combined(with: .opacity)) + } + } + .animation(reduceMotion ? nil : .easeInOut(duration: 0.15), value: scrubbing) + .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: showsVolume) + } + + private var transport: some View { + HStack(spacing: 0) { + control("Shuffle", "shuffle", size: 12, width: 18, active: player.state.isShuffled) { + player.send(.setShuffle(!player.state.isShuffled)) + } + SkipButton(backward: true) { player.send(.previous) } + .modifier(TransportControl(size: 14, width: 38)) + Button { player.send(.togglePlayPause) } label: { PlayPauseGlyph(isPlaying: player.isPlaying) } + .accessibilityLabel(player.isPlaying ? "Pause" : "Play") + .modifier(TransportControl(size: 24, width: 28)) + SkipButton(backward: false) { player.send(.next) } + .modifier(TransportControl(size: 14, width: 34)) + control( + "Repeat", + player.state.repeatMode == .one ? "repeat.1" : "repeat", + size: 12, + width: 18, + active: player.state.repeatMode != .off + ) { + let modes = RepeatMode.allCases + let index = modes.firstIndex(of: player.state.repeatMode) ?? 0 + player.send(.setRepeat(modes[(index + 1) % modes.count])) + } + } + .disabled(player.current == nil) + } + + private var metadata: some View { + ZStack { + nowPlaying + .opacity(scrubbing ? 0.15 : 1) + .blur(radius: scrubbing && !reduceMotion ? 8 : 0) + .allowsHitTesting(!scrubbing) + .accessibilityHidden(scrubbing) + MacTimeline(compact: true, expanded: scrubbing) { scrubbing = $0 } + } + .frame(height: 54) + .frame(maxWidth: .infinity) + } + + private var nowPlaying: some View { + HStack(spacing: 8) { + Button { router.showsNowPlaying = true } label: { + ArtworkView(artwork: player.current?.artwork, shape: .rounded(5), pixelSize: 128) + .frame(width: 34, height: 34) + .overlay { + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(.black.opacity(0.2)) + .overlay { + Image(systemName: "arrow.down.left.and.arrow.up.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + .scaleEffect(artworkHovered || reduceMotion ? 1 : 0.6) + } + .opacity(artworkHovered ? 1 : 0) + } + .scaleEffect(artworkHovered && !reduceMotion ? 1.1 : 1) + .animation(.easeOut(duration: 0.22), value: artworkHovered) + } + .overlay { MacHoverArea { artworkHovered = $0 } } + .buttonStyle(MacPlayerButtonStyle()) + .accessibilityLabel("Now Playing") + Menu { + if let track = player.current { + if let album = track.albumID { + Button("Go to Album", systemImage: "square.stack") { router.open(.album(album)) } + } + if let artist = track.artistID { + Button("Go to Artist", systemImage: "music.microphone") { router.open(.artist(artist)) } + } + } + } label: { + VStack(alignment: .leading, spacing: 0) { + Text(player.current?.title ?? String(localized: "Not Playing")) + .font(.system(size: 14, weight: .semibold)) + if let track = player.current { + Text("\(track.artistName) — \(track.albumTitle)") + .font(.system(size: 13)) + } + } + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .disabled(player.current == nil) + if let track = player.current { + Menu { TrackMenu(track: track) } label: { + Image(systemName: "ellipsis").font(.system(size: 16, weight: .bold)).frame(width: 34, height: 32) + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .accessibilityLabel("More") + } + } + .padding(.bottom, 1) + } + + private var panels: some View { + HStack(spacing: 8) { + panelToggle("Lyrics", "quote.bubble", .lyrics) + panelToggle("Queue", "list.bullet", .queue) + RoutePicker().frame(width: 28, height: 28).accessibilityLabel("AirPlay") + Button("Volume", systemImage: player.state.volume == 0 ? "speaker.slash.fill" : "speaker.wave.3.fill") { + volumeDismissal?.cancel() + showsVolume.toggle() + } + .frame(width: 28, height: 32) + } + .labelStyle(.iconOnly) + .buttonStyle(MacTransportButtonStyle(diameter: 36)) + .font(.system(size: 18)) + .opacity(showsVolume ? 0 : 1) + .allowsHitTesting(!showsVolume) + .accessibilityHidden(showsVolume) + } + + private func panelToggle(_ title: LocalizedStringKey, _ systemImage: String, _ panel: SidePanel) -> some View { + let selected = router.sidePanel == panel + return Button(title, systemImage: systemImage) { + let next: SidePanel? = selected ? nil : panel + withAnimation(router.sidePanel == nil || next == nil ? .default : nil) { router.sidePanel = next } + } + .frame(width: 28, height: 32) + .foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.primary)) + .accessibilityAddTraits(selected ? .isSelected : []) + } + + private func control( + _ title: LocalizedStringKey, _ symbol: String, size: CGFloat, width: CGFloat, + active: Bool? = nil, action: @escaping () -> Void + ) -> some View { + Button(title, systemImage: symbol, action: action) + .modifier(TransportControl(size: size, width: width, active: active)) + } + + private func volumeHover(_ inside: Bool) { + volumeHovered = inside + volumeDismissal?.cancel() + if inside || volumeTracking { + showsVolume = true + } else { + volumeDismissal = Task { + try? await Task.sleep(for: .milliseconds(350)) + if !Task.isCancelled { + showsVolume = false + } + } + } + } + } + + private struct TransportControl: ViewModifier { + let size: CGFloat + let width: CGFloat + var active: Bool? + + func body(content: Content) -> some View { + content + .labelStyle(.iconOnly) + .buttonStyle(MacTransportButtonStyle(diameter: 40)) + .font(.system(size: size, weight: .semibold)) + .foregroundStyle(active == true ? AnyShapeStyle(.tint) : AnyShapeStyle(.primary.opacity(active == false ? 0.3 : 0.9))) + .frame(width: width, height: 36) + .accessibilityAddTraits(active == true ? .isSelected : []) + } + } +#endif diff --git a/Amphlify/UI/Mac/MacRootView.swift b/Amphlify/UI/Mac/MacRootView.swift new file mode 100644 index 0000000..71b02f5 --- /dev/null +++ b/Amphlify/UI/Mac/MacRootView.swift @@ -0,0 +1,133 @@ +#if os(macOS) + import AppKit + import SwiftUI + + struct MacRootView: View { + @Environment(Router.self) private var router + @State private var selection: SidebarItem = .home + @State private var contentFrame: CGRect = .zero + @State private var nowPlayingPanel: SidePanel? = .lyrics + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + NavigationSplitView { + MacSidebar(selection: Binding { selection } set: { select($0) }) + .toolbar(removing: .sidebarToggle) + .toolbar { + ToolbarItem(id: MacWindowChrome.anchor) { Color.clear.frame(width: 1, height: 1) } + .sharedBackgroundVisibility(.hidden) + } + } detail: { + GeometryReader { proxy in + TabStack(tab: selection.tab) { selection.root } + .frame(width: proxy.size.width, height: proxy.size.height) + .safeAreaInset(edge: .bottom, spacing: 0) { Color.clear.frame(height: 84) } + } + .onGeometryChange(for: CGRect.self) { $0.frame(in: .named("macRoot")) } action: { contentFrame = $0 } + } + .inspector(isPresented: Binding { router.sidePanel != nil } set: { + if !$0 { + router.sidePanel = nil + } + }) { + MacSidePanel(panel: router.sidePanel ?? .lyrics) + .inspectorColumnWidth(min: 250, ideal: 290, max: 420) + } + .coordinateSpace(name: "macRoot") + .overlay { + GeometryReader { proxy in + let origin = proxy.frame(in: .named("macRoot")).origin + MacPlayerBar() + .frame(width: min(700, max(0, contentFrame.width - 40))) + .position(x: contentFrame.midX - origin.x, y: contentFrame.maxY - origin.y - 43) + } + } + .accessibilityHidden(router.showsNowPlaying) + .overlay { + GeometryReader { proxy in + if router.showsNowPlaying { + MacNowPlayingView(mode: $nowPlayingPanel) + .frame(width: proxy.size.width, height: proxy.size.height) + .geometryGroup() + .compositingGroup() + .transition(reduceMotion ? .opacity : .move(edge: .bottom)) + .zIndex(1) + } + } + .ignoresSafeArea() + .allowsHitTesting(router.showsNowPlaying) + } + .background { MacWindowChrome(expanded: router.showsNowPlaying) } + .animation(reduceMotion ? .easeOut(duration: 0.15) : .easeInOut(duration: 0.3), value: router.showsNowPlaying) + .tint(Color(nsColor: .controlAccentColor)) + .frame(minWidth: 1060, minHeight: 640) + .onChange(of: router.tab) { _, tab in + if selection.tab != tab { + selection = SidebarItem.landing(for: tab) + } + } + .modifier(MacPlaybackKeys()) + } + + private func select(_ item: SidebarItem) { + selection = item + router.tab = item.tab + router.paths[item.tab] = [] + } + } + + private struct MacPlaybackKeys: ViewModifier { + @Environment(Router.self) private var router + @Environment(PlayerModel.self) private var player + @State private var monitor: Any? + @State private var keyboardFocus = false + + func body(content: Content) -> some View { + content + .environment(\.macKeyboardFocus, keyboardFocus) + .focusEffectDisabled(!keyboardFocus) + .onAppear { + monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .leftMouseDown]) { event in + if event.type == .leftMouseDown { + keyboardFocus = false + return event + } + guard let window = event.window, window.isKeyWindow else { return event } + let editor = window.firstResponder as? NSTextView + let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift]) + switch event.keyCode { + case 48: + keyboardFocus = true + case 53: + return escape(in: window, editing: editor != nil) ? nil : event + case 49 where editor == nil && modifiers.isEmpty: + player.send(.togglePlayPause) + return nil + case 3 where modifiers == .command: + router.find() + return nil + case 123 ... 126 where editor != nil && modifiers.contains(.command): + editor?.keyDown(with: event) + return nil + default: + break + } + return event + } + } + .onDisappear { monitor.map(NSEvent.removeMonitor) } + } + + private func escape(in window: NSWindow, editing: Bool) -> Bool { + if editing { + window.makeFirstResponder(nil) + } else if router.showsNowPlaying { + router.showsNowPlaying = false + } else if !router.back() { + return false + } + return true + } + } + +#endif diff --git a/Amphlify/UI/Mac/MacSearchResults.swift b/Amphlify/UI/Mac/MacSearchResults.swift new file mode 100644 index 0000000..25ffa58 --- /dev/null +++ b/Amphlify/UI/Mac/MacSearchResults.swift @@ -0,0 +1,168 @@ +#if os(macOS) + import AppKit + import Domain + import Store + import SwiftUI + + struct SearchResultsView: View { + let results: SearchResults + let play: (Track) -> Void + + @State private var width: CGFloat = 0 + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 26) { + if !results.top.isEmpty { + top + } + if !results.artists.isEmpty { + artists + } + if !results.albums.isEmpty { + Shelf(title: "Albums", spacing: 20) { + ForEach(results.albums) { AlbumTile(album: $0).frame(width: 170) } + } + } + if !results.tracks.isEmpty { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Songs") + MacSongGrid(tracks: results.tracks) { play(results.tracks[$0]) } + } + } + if !results.playlists.isEmpty { + Shelf(title: "Playlists", spacing: 20) { + ForEach(results.playlists) { PlaylistTile(playlist: $0).frame(width: 170) } + } + } + } + .padding(.top, 16) + .padding(.bottom, 20) + } + } + + private var top: some View { + let columns = max(Int((width + 20) / 270), 1) + return VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Top Results") + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 20), count: columns), spacing: 20) { + ForEach(results.top.prefix(columns * 2)) { SearchCard(hit: $0, play: play) } + } + .padding(.horizontal, Metrics.margin) + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width = $0 - Metrics.margin * 2 } + } + } + + private var artists: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Artists") + ScrollView(.horizontal) { + LazyHStack(alignment: .top, spacing: 20) { + ForEach(results.artists) { artist in + NavigationLink(value: Route.artist(artist.id)) { + VStack(spacing: 8) { + ArtworkView(artwork: artist.artwork, shape: .circle).frame(width: 143, height: 143) + Text(artist.name).font(.system(size: 11, weight: .medium)).lineLimit(1) + } + .frame(width: 143) + } + .buttonStyle(.plain) + } + } + } + .scrollIndicators(.never) + .contentMargins(.horizontal, Metrics.margin, for: .scrollContent) + } + .padding(.bottom, 16) + } + } + + struct RecentSearchesView: View { + let hits: [SearchHit] + let play: (Track) -> Void + let clear: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + SectionHeader(title: "Recently Searched") + Spacer() + Button("Clear", action: clear) + .buttonStyle(.plain) + .foregroundStyle(.tint) + .padding(.trailing, Metrics.margin) + } + ScrollView(.horizontal) { + LazyHStack(spacing: 20) { + ForEach(hits) { SearchCard(hit: $0, height: 70, play: play).frame(width: 208) } + } + } + .scrollIndicators(.never) + .contentMargins(.horizontal, Metrics.margin, for: .scrollContent) + } + } + } + + private struct SearchCard: View { + let hit: SearchHit + var height: CGFloat = 80 + let play: (Track) -> Void + + var body: some View { + HStack(spacing: 0) { + if case let .track(track) = hit { + Button { play(track) } label: { label } + .contextMenu { TrackMenu(track: track) } + Menu { TrackMenu(track: track) } label: { + Image(systemName: "ellipsis").font(.system(size: 13, weight: .bold)).frame(width: 24, height: 24) + } + .menuStyle(.button) + .menuIndicator(.hidden) + .fixedSize() + .accessibilityLabel("More") + } else if let route = hit.route { + NavigationLink(value: route) { + HStack(spacing: 0) { + label + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 24) + } + } + } + } + .buttonStyle(.plain) + .padding(.leading, 14) + .padding(.trailing, 2) + .frame(height: height) + .background(Color.card, in: .rect(cornerRadius: 10, style: .continuous)) + } + + private var label: some View { + HStack(spacing: 13) { + ArtworkView(artwork: hit.artwork, shape: hit.shape, pixelSize: 128).frame(width: 42, height: 42) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(hit.title).font(.system(size: 15)) + if hit.isExplicit { + Image(systemName: "e.square.fill").font(.system(size: 10)).foregroundStyle(.secondary) + } + } + hit.subtitle.font(.system(size: 12)).foregroundStyle(.secondary) + } + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: .infinity) + .contentShape(.rect) + } + } + + private extension Color { + static let card = Color(nsColor: NSColor(name: nil) { appearance in + let white: CGFloat = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? 0x35 / 255 : 0xF2 / 255 + return NSColor(srgbRed: white, green: white, blue: white, alpha: 1) + }) + } +#endif diff --git a/Amphlify/UI/Mac/MacSettingsView.swift b/Amphlify/UI/Mac/MacSettingsView.swift new file mode 100644 index 0000000..591a791 --- /dev/null +++ b/Amphlify/UI/Mac/MacSettingsView.swift @@ -0,0 +1,48 @@ +#if os(macOS) + import Playback + import SwiftUI + + struct MacSettingsView: View { + @Environment(AppModel.self) private var app + @State private var device: OutputDeviceInfo? + + var body: some View { + @Bindable var settings = app.settings + Form { + Section { + Toggle("Match sample rate to the song", isOn: $settings.playback.matchSampleRate) + LabeledContent("Output Device") { + if let device { + Text(device.name) + } else { + Text("No Output Device") + } + } + if let device { + LabeledContent("Sample Rate") { + Text("\(kilohertz(device.nominalRate)) kHz") + } + } + } header: { + Text("Playback") + } footer: { + Text(Self.footer) + } + } + .formStyle(.grouped) + .frame(width: 480) + .task { + for await info in OutputDevice.changes() { + device = info + } + } + } + + private static let footer: LocalizedStringKey = + "The output device's sample rate is changed between songs to match each song. Some interfaces, such as UAD, may not like this." + + private func kilohertz(_ rate: Double) -> String { + (rate / 1000).formatted(.number.precision(.fractionLength(0 ... 1))) + } + } +#endif diff --git a/Amphlify/UI/Mac/MacSidePanel.swift b/Amphlify/UI/Mac/MacSidePanel.swift new file mode 100644 index 0000000..b215110 --- /dev/null +++ b/Amphlify/UI/Mac/MacSidePanel.swift @@ -0,0 +1,22 @@ +#if os(macOS) + import SwiftUI + + struct MacSidePanel: View { + let panel: SidePanel + + var body: some View { + Group { + switch panel { + case .lyrics: + LyricsView(bottomInset: 0, controls: { _ in }, seeks: true) + .environment(\.lyricsMetrics, LyricsMetrics(fontSize: 22, anchor: 84, blur: 0.4)) + .padding(.horizontal, 18) + case .queue: + MacQueuePanel().padding(.top, 52) + } + } + .ignoresSafeArea(edges: .top) + .transaction { $0.animation = nil } + } + } +#endif diff --git a/Amphlify/UI/Mac/MacSidebar.swift b/Amphlify/UI/Mac/MacSidebar.swift new file mode 100644 index 0000000..b1ec98b --- /dev/null +++ b/Amphlify/UI/Mac/MacSidebar.swift @@ -0,0 +1,177 @@ +#if os(macOS) + import Domain + import Store + import SwiftUI + + enum SidebarItem: Hashable { + case search, home, new, radio + case recentlyAdded, artists, albums, songs, genres + case allPlaylists, favorites + case playlist(EntityID) + + var tab: AppTab { + switch self { + case .search: .search + case .home: .home + case .radio: .radio + default: .library + } + } + + static func landing(for tab: AppTab) -> SidebarItem { + switch tab { + case .search: .search + case .home: .home + case .radio: .radio + case .library: .recentlyAdded + } + } + + @ViewBuilder var root: some View { + switch self { + case .search: SearchView() + case .home: HomeView() + case .new: MacAlbumsPage(title: "New", sort: .recentlyAdded) + case .radio: RadioView() + case .recentlyAdded: MacAlbumsPage(title: "Recently Added", sort: .recentlyAdded) + case .artists: ArtistsView() + case .albums: AlbumsView() + case .songs: SongsView() + case .genres: GenresView() + case .allPlaylists: PlaylistsView() + case .favorites: PlaylistDetailView(source: .favorites) + case let .playlist(id): PlaylistDetailView(source: .playlist(id)) + } + } + } + + struct MacSidebar: View { + @Binding var selection: SidebarItem + @Environment(AppModel.self) private var app + @State private var playlists: [Playlist] = [] + @State private var showsAccount = false + + var body: some View { + List(selection: $selection) { + Section { + row("Search", "magnifyingglass", .search) + Label { Text("Home") } icon: { HomeGlyph().foregroundStyle(Color(nsColor: .controlAccentColor)) }.padding(.leading, 9) + .tag(SidebarItem.home) + row("New", "square.grid.2x2", .new) + row("Radio", "dot.radiowaves.left.and.right", .radio) + } + Section("Library") { + row("Recently Added", "clock", .recentlyAdded) + row("Artists", "music.microphone", .artists) + row("Albums", "square.stack", .albums) + row("Songs", "music.note", .songs) + row("Genres", "guitars", .genres) + } + Section("Playlists") { + row("All Playlists", "square.grid.3x3", .allPlaylists) + row("Favorite Songs", "star.square", .favorites) + ForEach(playlists) { playlist in + Label { + Text(playlist.name) + } icon: { + ArtworkView(artwork: playlist.artwork, shape: .rounded(4), pixelSize: 64).frame(width: 18, height: 18) + } + .padding(.leading, 9) + .tag(SidebarItem.playlist(playlist.id)) + } + } + } + .listStyle(.sidebar) + .navigationSplitViewColumnWidth(min: 220, ideal: 254, max: 320) + .safeAreaInset(edge: .bottom, spacing: 0) { account } + .sheet(isPresented: $showsAccount) { AccountView() } + .task(id: app.revision) { playlists = await app.library.playlists() } + } + + private func row(_ title: LocalizedStringKey, _ systemImage: String, _ item: SidebarItem) -> some View { + Label { Text(title) } icon: { Image(systemName: systemImage).foregroundStyle(Color(nsColor: .controlAccentColor)) } + .padding(.leading, 9) + .tag(item) + } + + private var account: some View { + Button { showsAccount = true } label: { + HStack(spacing: 10) { + Text(app.sources.first?.name.prefix(1).uppercased() ?? "") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 22, height: 22) + .background(Color.indigo.mix(with: .black, by: 0.45).gradient, in: .circle) + Text(app.sources.first.flatMap { app.account(for: $0.id)?.username } ?? app.sources.first?.name ?? "") + .font(.system(size: 12)) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityLabel("Account") + } + } + + struct MacAlbumsPage: View { + let title: LocalizedStringKey + let sort: AlbumSort + + @Environment(AppModel.self) private var app + @State private var albums: [Album] = [] + + var body: some View { + ScrollView { + AlbumGrid(albums: albums).padding(.vertical, 12) + } + .pageTitle(title) + .task(id: app.revision) { albums = await app.library.albums(sortedBy: sort) } + } + } + + private struct HomeGlyph: View { + var body: some View { + Image(systemName: "house") + .hidden() + .overlay { HomeShape().fill(.foreground).aspectRatio(0.96, contentMode: .fit).padding(1) } + .accessibilityHidden(true) + } + } + + private nonisolated struct HomeShape: Shape { + func path(in rect: CGRect) -> Path { + let width = rect.width + let height = rect.height + let line = width * 0.08 + let inset = line / 2 + let eave = height * 0.41 + let corner = width * 0.08 + let base = width * 0.1 + var outline = Path() + outline.move(to: CGPoint(x: inset, y: height * 0.7)) + outline.addArc(tangent1End: CGPoint(x: inset, y: eave), tangent2End: CGPoint(x: width / 2, y: inset), radius: corner) + outline.addArc(tangent1End: CGPoint(x: width / 2, y: inset), tangent2End: CGPoint(x: width - inset, y: eave), radius: corner) + outline.addArc( + tangent1End: CGPoint(x: width - inset, y: eave), + tangent2End: CGPoint(x: width - inset, y: height - inset), + radius: corner + ) + outline.addArc( + tangent1End: CGPoint(x: width - inset, y: height - inset), + tangent2End: CGPoint(x: inset, y: height - inset), + radius: base + ) + outline.addArc(tangent1End: CGPoint(x: inset, y: height - inset), tangent2End: CGPoint(x: inset, y: eave), radius: base) + outline.closeSubpath() + let door = Path( + roundedRect: CGRect(x: width * 0.365, y: height * 0.61, width: width * 0.27, height: height * 0.39 - inset), + cornerRadius: width * 0.04, + style: .continuous + ) + return outline.strokedPath(StrokeStyle(lineWidth: line, lineJoin: .round)).union(door) + } + } +#endif diff --git a/Amphlify/UI/Mac/MacSlider.swift b/Amphlify/UI/Mac/MacSlider.swift new file mode 100644 index 0000000..41e7115 --- /dev/null +++ b/Amphlify/UI/Mac/MacSlider.swift @@ -0,0 +1,151 @@ +#if os(macOS) + import SwiftUI + + private struct MacKeyboardFocusKey: EnvironmentKey { + static let defaultValue = false + } + + extension EnvironmentValues { + var macKeyboardFocus: Bool { + get { self[MacKeyboardFocusKey.self] } + set { self[MacKeyboardFocusKey.self] = newValue } + } + } + + struct MacSlider: View { + let value: Double + let range: ClosedRange + var showsThumb = false + var thickness: CGFloat = 6 + var activeThickness: CGFloat = 8 + var height: CGFloat = 24 + var step: Double = 0.05 + var label = String(localized: "Volume") + var expanded = false + var onHover: (Bool) -> Void = { _ in } + var onFocus: (Bool) -> Void = { _ in } + var onTracking: (Bool) -> Void = { _ in } + var onScrub: (Double) -> Void = { _ in } + let onCommit: (Double) -> Void + + @Environment(\.isEnabled) private var isEnabled + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.macKeyboardFocus) private var keyboardFocus + @State private var dragged: Double? + @State private var hovered = false + @State private var focused = false + + private var active: Bool { + hovered || dragged != nil || (focused && keyboardFocus) || expanded + } + + var body: some View { + GeometryReader { proxy in + let thumb: CGFloat = showsThumb ? 24 : 0 + let inset = thumb / 2 + let width = max(proxy.size.width - thumb, 1) + let fraction = min(max(((dragged ?? value) - range.lowerBound) / span, 0), 1) + ZStack(alignment: .leading) { + Capsule().fill(Color.primary.opacity(0.24)) + .overlay(alignment: .leading) { + Rectangle().fill(Color.primary.opacity(active || showsThumb ? 1 : 0.5)) + .frame(width: inset + width * fraction) + } + .clipShape(.capsule) + .frame(height: active ? activeThickness : thickness) + if showsThumb { + Capsule().fill(.white) + .shadow(color: .black.opacity(0.25), radius: 1.5, y: 0.5) + .frame(width: thumb, height: 14) + .offset(x: width * fraction) + } + } + .compositingGroup() + .frame(maxHeight: .infinity) + .opacity(isEnabled ? 1 : 0.35) + .allowsHitTesting(false) + MacSliderInput( + value: value, range: range, step: step, inset: inset, label: label, enabled: isEnabled, + onScrub: { dragged = $0; onScrub($0) }, + onCommit: onCommit, + onTracking: { tracking in + if !tracking { + dragged = nil + } + onTracking(tracking) + }, + onHover: { hovered = $0; onHover($0) }, + onFocus: { focused = $0; onFocus($0 && keyboardFocus) } + ) + .frame(width: proxy.size.width, height: proxy.size.height) + } + .frame(height: height) + .overlay { + if focused, keyboardFocus { + RoundedRectangle(cornerRadius: 4).strokeBorder(.tint, lineWidth: 2).padding(-3) + } + } + .onChange(of: keyboardFocus) { _, enabled in onFocus(focused && enabled) } + .animation(reduceMotion ? nil : .easeInOut(duration: 0.15), value: active) + } + + private var span: Double { + max(range.upperBound - range.lowerBound, .leastNonzeroMagnitude) + } + } + + struct MacVolumeControl: View { + var compact = false + var onTracking: (Bool) -> Void = { _ in } + @Environment(PlayerModel.self) private var player + + var body: some View { + HStack(spacing: compact ? 8 : 10) { + MacSlider( + value: player.state.volume, range: 0 ... 1, + showsThumb: !compact, thickness: compact ? 8 : 4, + activeThickness: compact ? 8 : 6, height: 28, expanded: compact, + onTracking: onTracking, + onScrub: { player.setVolume($0) }, onCommit: { player.setVolume($0) } + ) + Button(player.state.volume == 0 ? "Unmute" : "Mute", systemImage: symbol) { + player.toggleMute() + } + .labelStyle(.iconOnly) + .buttonStyle(MacPlayerButtonStyle()) + .font(.system(size: compact ? 18 : 17)) + .frame(width: 26, height: 28) + } + } + + private var symbol: String { + switch player.state.volume { + case 0: "speaker.slash.fill" + case ..<0.33: "speaker.wave.1.fill" + case ..<0.67: "speaker.wave.2.fill" + default: "speaker.wave.3.fill" + } + } + } + + struct MacPlayerButtonStyle: ButtonStyle { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .contentShape(.rect) + .opacity(configuration.isPressed ? 0.55 : 1) + .animation(reduceMotion ? nil : .easeOut(duration: 0.12), value: configuration.isPressed) + } + } + + struct MacTransportButtonStyle: ButtonStyle { + let diameter: CGFloat + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .contentShape(.rect) + .modifier(PressHighlight(isPressed: configuration.isPressed, fill: .black.opacity(0.4), diameter: diameter)) + } + } +#endif diff --git a/Amphlify/UI/Mac/MacSliderInput.swift b/Amphlify/UI/Mac/MacSliderInput.swift new file mode 100644 index 0000000..dae02f3 --- /dev/null +++ b/Amphlify/UI/Mac/MacSliderInput.swift @@ -0,0 +1,181 @@ +#if os(macOS) + import AppKit + import SwiftUI + + struct MacSliderInput: NSViewRepresentable { + let value: Double + let range: ClosedRange + let step: Double + let inset: CGFloat + let label: String + let enabled: Bool + let onScrub: (Double) -> Void + let onCommit: (Double) -> Void + let onTracking: (Bool) -> Void + let onHover: (Bool) -> Void + let onFocus: (Bool) -> Void + + func makeNSView(context _: Context) -> InputView { + let view = InputView() + view.focusRingType = .none + return view + } + + func updateNSView(_ view: InputView, context _: Context) { + view.input = self + view.setAccessibilityLabel(label) + view.setAccessibilityEnabled(enabled) + } + + final class InputView: NSView { + var input: MacSliderInput? + private var trackingArea: NSTrackingArea? + private var dragged: Double? + private var tracking = false + private var holdsWindow = false + + override var acceptsFirstResponder: Bool { + input?.enabled == true + } + + override var mouseDownCanMoveWindow: Bool { + false + } + + override func acceptsFirstMouse(for _: NSEvent?) -> Bool { + true + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea { + removeTrackingArea(trackingArea) + } + let area = NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], owner: self) + addTrackingArea(area) + trackingArea = area + DispatchQueue.main.async { [weak self] in + guard let self, let window else { return } + hover(bounds.contains(convert(window.mouseLocationOutsideOfEventStream, from: nil))) + } + } + + override func viewWillMove(toWindow newWindow: NSWindow?) { + if newWindow == nil { + holdWindow(false) + } + super.viewWillMove(toWindow: newWindow) + } + + override func mouseEntered(with _: NSEvent) { + hover(true) + } + + override func mouseExited(with _: NSEvent) { + hover(false) + } + + override func mouseDown(with event: NSEvent) { + guard input?.enabled == true else { return } + input?.onFocus(false) + tracking = true + input?.onTracking(true) + scrub(event) + } + + override func mouseDragged(with event: NSEvent) { + scrub(event) + } + + override func mouseUp(with event: NSEvent) { + guard input?.enabled == true else { return } + scrub(event) + if let dragged { + input?.onCommit(dragged) + } + dragged = nil + tracking = false + input?.onTracking(false) + holdWindow(bounds.contains(convert(event.locationInWindow, from: nil))) + } + + override func becomeFirstResponder() -> Bool { + input?.onFocus(true) + return true + } + + override func resignFirstResponder() -> Bool { + input?.onFocus(false) + return true + } + + override func keyDown(with event: NSEvent) { + switch event.keyCode { + case 123, 125: adjust(-1) + case 124, 126: adjust(1) + default: super.keyDown(with: event) + } + } + + override func isAccessibilityElement() -> Bool { + true + } + + override func accessibilityRole() -> NSAccessibility.Role? { + .slider + } + + override func accessibilityValue() -> Any? { + dragged ?? input?.value + } + + override func accessibilityMinValue() -> Any? { + input?.range.lowerBound + } + + override func accessibilityMaxValue() -> Any? { + input?.range.upperBound + } + + override func accessibilityPerformIncrement() -> Bool { + adjust(1); return true + } + + override func accessibilityPerformDecrement() -> Bool { + adjust(-1); return true + } + + override func setAccessibilityValue(_ value: Any?) { + guard let number = value as? NSNumber, let input, input.enabled else { return } + input.onCommit(min(max(number.doubleValue, input.range.lowerBound), input.range.upperBound)) + } + + private func scrub(_ event: NSEvent) { + guard let input, input.enabled else { return } + let point = convert(event.locationInWindow, from: nil) + let fraction = min(max((point.x - input.inset) / max(bounds.width - input.inset * 2, 1), 0), 1) + let value = input.range.lowerBound + fraction * (input.range.upperBound - input.range.lowerBound) + dragged = value + input.onScrub(value) + } + + private func hover(_ inside: Bool) { + input?.onHover(inside) + if inside || !tracking { + holdWindow(inside) + } + } + + private func holdWindow(_ hold: Bool) { + guard hold != holdsWindow else { return } + holdsWindow = hold + window?.isMovable = !hold + } + + private func adjust(_ direction: Double) { + guard let input, input.enabled else { return } + input.onCommit(min(max(input.value + input.step * direction, input.range.lowerBound), input.range.upperBound)) + } + } + } +#endif diff --git a/Amphlify/UI/Mac/MacSongGrid.swift b/Amphlify/UI/Mac/MacSongGrid.swift new file mode 100644 index 0000000..6c343c4 --- /dev/null +++ b/Amphlify/UI/Mac/MacSongGrid.swift @@ -0,0 +1,70 @@ +#if os(macOS) + import Domain + import SwiftUI + + struct MacSongGrid: View { + let tracks: [Track] + var subtitle: TrackRow.Subtitle = .artist + var leading = Metrics.margin + let play: (Int) -> Void + + var body: some View { + let rows = min(tracks.count, 3) + ScrollView(.horizontal) { + LazyHGrid(rows: Array(repeating: GridItem(.fixed(56), spacing: 0), count: rows), spacing: 20) { + ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in + row(track, index: index, separated: index % rows != rows - 1 && index != tracks.count - 1) + } + } + } + .scrollIndicators(.never) + .contentMargins(.leading, leading, for: .scrollContent) + .contentMargins(.trailing, Metrics.margin, for: .scrollContent) + } + + private func row(_ track: Track, index: Int, separated: Bool) -> some View { + HStack(spacing: 12) { + ArtworkView(artwork: track.artwork, shape: .rounded(4), pixelSize: 128).frame(width: 48, height: 48) + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(track.title) + if track.isExplicit { + Image(systemName: "e.square.fill").font(.system(size: 10)).foregroundStyle(.secondary) + } + } + Text(details(of: track)).foregroundStyle(.secondary) + } + .font(.system(size: 13)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + Menu { TrackMenu(track: track) } label: { + Image(systemName: "ellipsis").font(.system(size: 13, weight: .semibold)).frame(width: 24, height: 24) + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + .accessibilityLabel("More") + } + .frame(maxHeight: .infinity) + .overlay(alignment: .bottom) { + if separated { + Rectangle().fill(.separator).frame(height: 1) + } + } + } + .frame(width: 360) + .contentShape(.rect) + .onTapGesture(count: 2) { play(index) } + .contextMenu { TrackMenu(track: track) } + } + + private func details(of track: Track) -> String { + switch subtitle { + case .album: [track.albumTitle, track.year.map(String.init)].compactMap(\.self).joined(separator: " · ") + default: track.artistName + } + } + } +#endif diff --git a/Amphlify/UI/Mac/MacTrackRow.swift b/Amphlify/UI/Mac/MacTrackRow.swift new file mode 100644 index 0000000..c5641ab --- /dev/null +++ b/Amphlify/UI/Mac/MacTrackRow.swift @@ -0,0 +1,81 @@ +#if os(macOS) + import Domain + import SwiftUI + + struct MacTrackRow: View { + let track: Track + let leading: TrackRow.Leading + let subtitle: TrackRow.Subtitle + let action: () -> Void + + @Environment(AppModel.self) private var app + @Environment(PlayerModel.self) private var player + @State private var hovered = false + + var body: some View { + HStack(spacing: 14) { + lead + HStack(spacing: 4) { + Text(track.title).lineLimit(1) + if track.isExplicit { + Image(systemName: "e.square.fill").font(.system(size: 9)).foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + if subtitle != .none { + Text(track.artistName).lineLimit(1).foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .leading) + } + if subtitle == .album { + Text(track.albumTitle).lineLimit(1).foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .leading) + } + Text(track.duration.clock).foregroundStyle(.secondary).monospacedDigit() + Menu { TrackMenu(track: track) } label: { + Image(systemName: "ellipsis").font(.system(size: 12, weight: .semibold)).frame(width: 20, height: 20) + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .foregroundStyle(.secondary) + .accessibilityLabel("More") + } + .font(.system(size: 13)) + .padding(.horizontal, 10) + .frame(height: leading == .artwork ? 48 : 46) + .background(hovered ? Color.primary.opacity(0.06) : .clear, in: .rect(cornerRadius: 5)) + .contentShape(.rect) + .onHover { hovered = $0 } + .onTapGesture(count: 2, perform: action) + .padding(.horizontal, Metrics.margin - 10) + .contextMenu { TrackMenu(track: track) } + } + + @ViewBuilder private var lead: some View { + let isCurrent = player.isCurrent(track) + switch leading { + case .number: + ZStack { + if isCurrent { + PlayingIndicator() + } else { + Text(track.trackNumber, format: .number).foregroundStyle(.secondary).monospacedDigit() + } + } + .frame(width: 22, alignment: .trailing) + .overlay(alignment: .leading) { + if app.favorites.contains(track.id) { + Image(systemName: "star.fill").font(.system(size: 7)).foregroundStyle(.tint).offset(x: -18) + } + } + case .artwork: + ArtworkView(artwork: track.artwork, shape: .rounded(4), pixelSize: 128) + .frame(width: 36, height: 36) + .overlay { + if isCurrent { + RoundedRectangle(cornerRadius: 4, style: .continuous).fill(.black.opacity(0.45)) + PlayingIndicator().foregroundStyle(.white) + } + } + } + } + } +#endif diff --git a/Amphlify/UI/Mac/MacWindowChrome.swift b/Amphlify/UI/Mac/MacWindowChrome.swift new file mode 100644 index 0000000..b72d791 --- /dev/null +++ b/Amphlify/UI/Mac/MacWindowChrome.swift @@ -0,0 +1,64 @@ +#if os(macOS) + import AppKit + import SwiftUI + + struct MacWindowChrome: NSViewRepresentable { + static let anchor = "window-anchor" + let expanded: Bool + + func makeNSView(context _: Context) -> ChromeView { + ChromeView() + } + + func updateNSView(_ view: ChromeView, context _: Context) { + view.expanded = expanded + DispatchQueue.main.async { view.updateChrome() } + } + + final class ChromeView: NSView { + private func sidebars(in view: NSView) -> [NSOutlineView] { + (view as? NSOutlineView).map { [$0] } ?? view.subviews.flatMap(sidebars) + } + + var expanded = false + private var hiddenItems: [NSToolbarItem.Identifier: Bool] = [:] + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateChrome() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.updateChrome() } + } + + func updateChrome() { + guard let window else { return } + window.isMovableByWindowBackground = false + if expanded { + for item in window.toolbar?.items ?? [] where item.itemIdentifier.rawValue != MacWindowChrome.anchor { + if hiddenItems[item.itemIdentifier] == nil { + hiddenItems[item.itemIdentifier] = item.isHidden + } + item.isHidden = true + } + } else { + for item in window.toolbar?.items ?? [] { + if let hidden = hiddenItems[item.itemIdentifier] { + item.isHidden = hidden + } + } + hiddenItems.removeAll() + } + for sidebar in window.contentView.map({ sidebars(in: $0) }) ?? [] { + sidebar.refusesFirstResponder = true + if window.firstResponder === sidebar { + window.makeFirstResponder(nil) + } + } + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = expanded + for type in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { + window.standardWindowButton(type)?.isHidden = false + } + } + } + } +#endif diff --git a/Amphlify/UI/Mac/NowPlaying/MacNowPlayingControls.swift b/Amphlify/UI/Mac/NowPlaying/MacNowPlayingControls.swift new file mode 100644 index 0000000..cc1161a --- /dev/null +++ b/Amphlify/UI/Mac/NowPlaying/MacNowPlayingControls.swift @@ -0,0 +1,144 @@ +#if os(macOS) + import Playback + import SwiftUI + + struct MacNowPlayingControls: View { + @Environment(AppModel.self) private var app + @Environment(PlayerModel.self) private var player + + let artworkSize: CGFloat + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + VStack(spacing: 0) { + ArtworkView(artwork: player.current?.artwork, shape: .rounded(10), pixelSize: 1024) + .shadow(color: .black.opacity(0.3), radius: 20, y: 10) + .frame(width: artworkSize, height: artworkSize) + .scaleEffect(player.isPlaying ? 1 : 0.73) + .animation(reduceMotion ? nil : .spring(response: 0.25, dampingFraction: 1), value: player.isPlaying) + titleRow.padding(.top, 20) + MacTimeline().padding(.top, 14) + transport.padding(.top, 14) + } + .frame(width: artworkSize) + .frame(maxHeight: .infinity) + } + + private var titleRow: some View { + HStack(spacing: 6) { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 4) { + Text(player.current?.title ?? String(localized: "Not Playing")) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.white) + if player.current?.isExplicit == true { + Image(systemName: "e.square.fill").font(.system(size: 10)).foregroundStyle(.white.opacity(0.55)) + } + } + Text(subtitle).font(.system(size: 15)).foregroundStyle(.white.opacity(0.65)) + } + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + if let track = player.current { + if app.canFavorite(track.id) { + let isFavorite = app.favorites.contains(track.id) + Button( + isFavorite ? "Undo Favorite" as LocalizedStringKey : "Favorite", + systemImage: isFavorite ? "star.fill" : "star" + ) { + app.toggleFavorite(track.id, kind: .track) + } + .modifier(MacRoundGlyph(size: 13)) + .symbolEffect(.bounce, value: isFavorite) + } + Menu { TrackMenu(track: track) } label: { + Label("More", systemImage: "ellipsis").modifier(MacRoundGlyph(size: 15)) + } + .menuStyle(.button) + .menuIndicator(.hidden) + .buttonStyle(MacPlayerButtonStyle()) + .fixedSize() + } + } + } + + private var subtitle: String { + guard let track = player.current else { return " " } + return [track.artistName, track.albumTitle].map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + .joined(separator: " — ") + } + + private var transport: some View { + HStack(spacing: 0) { + Button("Shuffle", systemImage: "shuffle") { player.send(.setShuffle(!player.state.isShuffled)) } + .modifier(MacModeGlyph(isActive: player.state.isShuffled)) + Spacer() + HStack(spacing: 50) { + SkipButton(backward: true) { player.send(.previous) } + .font(.system(size: 25)) + .modifier(MacTransportGlyph()) + Button { player.send(.togglePlayPause) } label: { PlayPauseGlyph(isPlaying: player.isPlaying) } + .accessibilityLabel(player.isPlaying ? "Pause" : "Play") + .font(.system(size: 33)) + .modifier(MacTransportGlyph()) + SkipButton(backward: false) { player.send(.next) } + .font(.system(size: 25)) + .modifier(MacTransportGlyph()) + } + Spacer() + Button("Repeat", systemImage: player.state.repeatMode == .one ? "repeat.1" : "repeat") { + let modes = RepeatMode.allCases + let index = modes.firstIndex(of: player.state.repeatMode) ?? 0 + player.send(.setRepeat(modes[(index + 1) % modes.count])) + } + .modifier(MacModeGlyph(isActive: player.state.repeatMode != .off)) + } + .labelStyle(.iconOnly) + .buttonStyle(MacPlayerButtonStyle()) + .disabled(player.current == nil) + .padding(.horizontal, 2) + } + } + + private struct MacRoundGlyph: ViewModifier { + let size: CGFloat + + func body(content: Content) -> some View { + content + .labelStyle(.iconOnly) + .font(.system(size: size, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 25, height: 25) + .background(.white.opacity(0.18), in: .circle) + .contentShape(.circle) + .buttonStyle(MacPlayerButtonStyle()) + } + } + + private struct MacTransportGlyph: ViewModifier { + @Environment(\.isEnabled) private var isEnabled + + func body(content: Content) -> some View { + content + .foregroundStyle(.white.opacity(isEnabled ? 1 : 0.35)) + .frame(width: 36, height: 36) + } + } + + private struct MacModeGlyph: ViewModifier { + let isActive: Bool + + func body(content: Content) -> some View { + content + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white.opacity(isActive ? 1 : 0.6)) + .frame(width: 30, height: 30) + .background { + if isActive { + RoundedRectangle(cornerRadius: 5, style: .continuous).fill(.white.opacity(0.25)) + } + } + .contentShape(.rect) + } + } +#endif diff --git a/Amphlify/UI/Mac/NowPlaying/MacQueuePanel.swift b/Amphlify/UI/Mac/NowPlaying/MacQueuePanel.swift new file mode 100644 index 0000000..cb4fee2 --- /dev/null +++ b/Amphlify/UI/Mac/NowPlaying/MacQueuePanel.swift @@ -0,0 +1,124 @@ +#if os(macOS) + import Playback + import SwiftUI + + struct MacQueuePanel: View { + @Environment(PlayerModel.self) private var player + + private static let upcoming = "upcoming" + + var body: some View { + ScrollViewReader { proxy in + List { + if !player.state.history.isEmpty { + Section { + ForEach(player.state.history) { row($0, upcoming: false, last: $0.id == player.state.history.last?.id) } + } header: { + header("History", clear: .clearHistory) + } + } + Section { + if player.state.upcoming.isEmpty { + Text("There’s no music in the queue.") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 120) + .listRowSeparator(.hidden) + } + ForEach(player.state.upcoming) { row($0, upcoming: true, last: $0.id == player.state.upcoming.last?.id) } + .onMove(perform: move) + } header: { + header("Continue Playing", subtitle: source, clear: player.state.upcoming.isEmpty ? nil : .clearUpcoming) + .id(Self.upcoming) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .environment(\.defaultMinListRowHeight, 48) + .onAppear { proxy.scrollTo(Self.upcoming, anchor: .top) } + .onChange(of: player.state.current?.id) { proxy.scrollTo(Self.upcoming, anchor: .top) } + } + } + + private var source: String? { + let album = player.current?.albumTitle.trimmingCharacters(in: .whitespaces) ?? "" + return album.isEmpty ? nil : album + } + + private func header(_ title: LocalizedStringKey, subtitle: String? = nil, clear: PlaybackCommand?) -> some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 3) { + Text(title).font(.system(size: 15, weight: .bold)).foregroundStyle(.primary) + if let subtitle { + Text("From \(subtitle)").font(.system(size: 12)).foregroundStyle(.secondary) + } + } + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + if let clear { + Button("Clear") { player.send(clear) } + .buttonStyle(.plain) + .font(.system(size: 13)) + .foregroundStyle(.tint) + } + } + .padding(.top, 14) + .padding(.bottom, 8) + } + + private func row(_ item: QueueItem, upcoming: Bool, last: Bool) -> some View { + HStack(spacing: 10) { + ArtworkView(artwork: item.track.artwork, shape: .rounded(4), pixelSize: 128).frame(width: 34, height: 34) + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 1) { + Text(item.track.title).font(.system(size: 13)) + Text([item.track.artistName, item.track.albumTitle].map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty }.joined(separator: " — ")) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + Menu { menu(item, upcoming: upcoming) } label: { + Image(systemName: "ellipsis") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + .contentShape(.rect) + } + .menuStyle(.button) + .menuIndicator(.hidden) + .buttonStyle(.plain) + .fixedSize() + .accessibilityLabel("More") + } + .frame(maxHeight: .infinity) + .overlay(alignment: .bottom) { Rectangle().fill(.separator).frame(height: 1).opacity(last ? 0 : 1) } + } + .frame(height: 48) + .contentShape(.rect) + .onTapGesture(count: 2) { player.send(.jump(to: item.id)) } + .contextMenu { menu(item, upcoming: upcoming) } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + } + + @ViewBuilder private func menu(_ item: QueueItem, upcoming: Bool) -> some View { + TrackMenu(track: item.track) + if upcoming { + Divider() + Button("Remove from queue", systemImage: "trash", role: .destructive) { player.send(.remove(item.id)) } + } + } + + private func move(from offsets: IndexSet, to destination: Int) { + let upcoming = player.state.upcoming + guard let from = offsets.first else { return } + var reordered = upcoming + reordered.move(fromOffsets: offsets, toOffset: destination) + guard let landed = reordered.firstIndex(where: { $0.id == upcoming[from].id }) else { return } + player.send(.move(item: upcoming[from].id, after: landed > 0 ? reordered[landed - 1].id : nil)) + } + } +#endif diff --git a/Amphlify/UI/Mac/NowPlaying/MacTimeline.swift b/Amphlify/UI/Mac/NowPlaying/MacTimeline.swift new file mode 100644 index 0000000..5a314c3 --- /dev/null +++ b/Amphlify/UI/Mac/NowPlaying/MacTimeline.swift @@ -0,0 +1,91 @@ +#if os(macOS) + import SwiftUI + + struct MacTimeline: View { + var compact = false + var expanded = false + var onActiveChange: (Bool) -> Void = { _ in } + @Environment(PlayerModel.self) private var player + @State private var scrubbed: Double? + @State private var seekTask: Task? + @State private var request = 0 + @State private var hovered = false + @State private var tracking = false + @State private var focused = false + + var body: some View { + let duration = player.current?.duration ?? 0 + TimelineView(.animation(minimumInterval: 1 / 30, paused: !player.isPlaying)) { _ in + let position = scrubbed ?? player.position() + if compact { + GeometryReader { proxy in + times(position, duration: duration) + .font(.system(size: 13, weight: .medium).monospacedDigit()) + .opacity(expanded ? 1 : 0) + .accessibilityHidden(!expanded) + .position(x: proxy.size.width / 2, y: 27) + .allowsHitTesting(false) + slider(position, duration: duration) + .position(x: proxy.size.width / 2, y: expanded ? 41 : 49) + } + .frame(height: 54) + } else { + VStack(spacing: 0) { + slider(position, duration: duration) + times(position, duration: duration) + .font(.system(size: 10, weight: .medium).monospacedDigit()) + .foregroundStyle(.secondary) + } + } + } + .disabled(duration <= 0) + .onHover { inside in + if !inside { + hovered = false + } + } + .onChange(of: player.state.current?.id) { + request += 1 + seekTask?.cancel() + scrubbed = nil + tracking = false + } + .onChange(of: hovered || tracking || focused) { _, active in onActiveChange(active) } + } + + private func slider(_ position: Double, duration: Double) -> some View { + MacSlider( + value: position, range: 0 ... max(duration, 1), + thickness: compact ? 2 : 6, activeThickness: 8, + height: compact ? (expanded ? 24 : 8) : 24, + step: 15, label: String(localized: "Track position"), expanded: expanded, + onHover: { hovered = $0 }, onFocus: { focused = $0 }, onTracking: { tracking = $0 }, + onScrub: { scrubbed = $0 }, onCommit: { seek(to: $0) } + ) + } + + private func times(_ position: Double, duration: Double) -> some View { + HStack { + Text(position.clock) + Spacer() + if !compact, let format = player.current?.formats.first { + Label(format.badge, systemImage: "waveform").labelIconToTitleSpacing(3) + Spacer() + } + Text("−" + max(duration - position, 0).clock) + } + } + + private func seek(to position: Double) { + scrubbed = position + request += 1 + let currentRequest = request + seekTask?.cancel() + seekTask = Task { + await player.seek(to: position) + guard !Task.isCancelled, currentRequest == request else { return } + scrubbed = nil + } + } + } +#endif diff --git a/Amphlify/UI/Player/Backdrop.swift b/Amphlify/UI/Player/Backdrop.swift index d510f41..93e1dd4 100644 --- a/Amphlify/UI/Player/Backdrop.swift +++ b/Amphlify/UI/Player/Backdrop.swift @@ -38,8 +38,8 @@ struct Backdrop: View { .animation(.easeOut(duration: 0.35), value: layers.map(\.id)) .ignoresSafeArea() .task(id: artwork) { - guard let artwork, let image = await app.artwork.image(artwork, pixelSize: 128), - let (dark, light) = Palette.pair(of: image) else { return } + guard let artwork, let bitmap = await app.artwork.image(artwork, pixelSize: 128)?.bitmap, + let (dark, light) = await Palette.pair(of: bitmap) else { return } let layer = Layer(dark: dark, light: light) layers.append(layer) try? await Task.sleep(for: .seconds(0.5)) @@ -52,11 +52,11 @@ struct Backdrop: View { } } -private enum Palette { +private nonisolated enum Palette { private static let side = 32 - static func pair(of image: UIImage) -> (Color, Color)? { - guard let pixels = samples(of: image) else { return nil } + @concurrent static func pair(of bitmap: CGImage) async -> (Color, Color)? { + guard let pixels = samples(of: bitmap) else { return nil } let mean = pixels.reduce(SIMD3()) { $0 + $1 } / Double(pixels.count) let target = 0.42 * luminance(mean) + 22 var clusters = kmeans(pixels, count: 5) @@ -98,8 +98,8 @@ private enum Palette { } } - private static func samples(of image: UIImage) -> [SIMD3]? { - guard let bitmap = image.cgImage, let srgb = CGColorSpace(name: CGColorSpace.sRGB), + private static func samples(of bitmap: CGImage) -> [SIMD3]? { + guard let srgb = CGColorSpace(name: CGColorSpace.sRGB), let canvas = CGContext( data: nil, width: side, diff --git a/Amphlify/UI/Player/LyricsView.swift b/Amphlify/UI/Player/LyricsView.swift index ec3a147..84190fe 100644 --- a/Amphlify/UI/Player/LyricsView.swift +++ b/Amphlify/UI/Player/LyricsView.swift @@ -19,11 +19,18 @@ struct LyricsView: View { @State private var scroll: Scroll? @State private var pressed: Press? @State private var seeked: Int? + @Environment(\.lyricsMetrics) private var metrics - private let anchor: CGFloat = 74 - private let blockSpacing: CGFloat = 27 private let idleReturn: TimeInterval = 3.5 + private var anchor: CGFloat { + metrics.anchor + } + + private var blockSpacing: CGFloat { + 27 * metrics.scale + } + private struct Scroll { struct Return { let from: CGFloat @@ -68,7 +75,7 @@ struct LyricsView: View { } else { Text("No lyrics") .font(.headline) - .foregroundStyle(.white.opacity(0.5)) + .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity) } } @@ -220,8 +227,7 @@ struct LyricsView: View { } private func tapped(at point: CGPoint, entries: [Entry]) { - controls(.reveal) - guard seeks else { return } + guard seeks else { return controls(.reveal) } let frame = frame(entries, date: .now) for index in entries.indices { let top = displayed(index, frame: frame) @@ -239,6 +245,7 @@ struct LyricsView: View { } return } + controls(.reveal) } static func spring(_ elapsed: TimeInterval, response: Double) -> Double { @@ -296,7 +303,7 @@ struct LyricsView: View { let bounds = (line.text as NSString).boundingRect( with: CGSize(width: max(size.width - 36, 1), height: .greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], - attributes: [.font: LyricLineView.font], + attributes: [.font: LyricLineView.font(size: metrics.fontSize)], context: nil ) return ceil(bounds.height) @@ -323,8 +330,30 @@ struct LyricsView: View { } } +struct LyricsMetrics { + var fontSize: CGFloat = 33 + var anchor: CGFloat = 74 + var blur: CGFloat = 1 + + var scale: CGFloat { + fontSize / 33 + } +} + +extension EnvironmentValues { + @Entry var lyricsMetrics = LyricsMetrics() +} + private struct LyricLineView: View { - static let font = UIFont.systemFont(ofSize: 33, weight: .bold) + #if canImport(UIKit) + static func font(size: CGFloat) -> UIFont { + .systemFont(ofSize: size, weight: .bold) + } + #else + static func font(size: CGFloat) -> NSFont { + .systemFont(ofSize: size, weight: .bold) + } + #endif struct State: Equatable { let distance: Int @@ -351,19 +380,20 @@ private struct LyricLineView: View { let text: String let state: State let highlight: Double + @Environment(\.lyricsMetrics) private var metrics var body: some View { Text(text) - .font(.system(size: 33, weight: .bold)) - .foregroundStyle(.white.opacity(state.opacity)) - .blur(radius: state.blur) + .font(.system(size: metrics.fontSize, weight: .bold)) + .foregroundStyle(.primary.opacity(state.opacity)) + .blur(radius: state.blur * metrics.blur) .animation(.easeInOut(duration: state.uniform ? 0.12 : 0.25), value: state) .padding(.leading, 2) .padding(.trailing, 34) .frame(maxWidth: .infinity, alignment: .leading) .background { RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.white.opacity(0.13 * highlight)) + .fill(.primary.opacity(0.13 * highlight)) .padding(.vertical, -14) } } @@ -372,18 +402,19 @@ private struct LyricLineView: View { private struct IntroDots: View { let progress: Double let state: LyricLineView.State + @Environment(\.lyricsMetrics) private var metrics var body: some View { HStack(spacing: 10) { ForEach(0 ..< 3) { dot in Circle() - .fill(.white.opacity(progress * 3 > Double(dot) + 0.5 ? 1 : 0.4)) + .fill(.primary.opacity(progress * 3 > Double(dot) + 0.5 ? 1 : 0.4)) .frame(width: 15, height: 15) } } .padding(.vertical, 12) .opacity(state.uniform ? 0.5 : state.distance == 0 ? 1 : 0.3) - .blur(radius: state.blur) + .blur(radius: state.blur * metrics.blur) .animation(.easeInOut(duration: 0.3), value: progress * 3 > 1.5) } } diff --git a/Amphlify/UI/Player/MiniPlayer.swift b/Amphlify/UI/Player/MiniPlayer.swift index d319d12..634b2ca 100644 --- a/Amphlify/UI/Player/MiniPlayer.swift +++ b/Amphlify/UI/Player/MiniPlayer.swift @@ -1,48 +1,48 @@ -import SwiftUI +#if os(iOS) + import SwiftUI -struct MiniPlayer: View { - @Environment(PlayerModel.self) private var player - @Environment(Router.self) private var router - @Environment(\.tabViewBottomAccessoryPlacement) private var placement + struct MiniPlayer: View { + @Environment(PlayerModel.self) private var player + @Environment(Router.self) private var router + @Environment(\.tabViewBottomAccessoryPlacement) private var placement - var body: some View { - HStack(spacing: 10) { - Button { - router.showsNowPlaying = true - } label: { - HStack(spacing: 10) { - ArtworkView(artwork: player.current?.artwork, shape: .rounded(7)).frame(width: 36, height: 36) - VStack(alignment: .leading, spacing: 0) { - Text(player.current?.title ?? String(localized: "Not Playing")).font(.subheadline.weight(.semibold)) - if let artist = player.current?.artistName, placement != .inline { - Text(artist).font(.footnote).foregroundStyle(.secondary) + var body: some View { + HStack(spacing: 10) { + Button { + router.showsNowPlaying = true + } label: { + HStack(spacing: 10) { + ArtworkView(artwork: player.current?.artwork, shape: .rounded(7)).frame(width: 36, height: 36) + VStack(alignment: .leading, spacing: 0) { + Text(player.current?.title ?? String(localized: "Not Playing")).font(.subheadline.weight(.semibold)) + if let artist = player.current?.artistName, placement != .inline { + Text(artist).font(.footnote).foregroundStyle(.secondary) + } } + .lineLimit(1) + Spacer(minLength: 0) } - .lineLimit(1) - Spacer(minLength: 0) + .contentShape(.rect) } - .contentShape(.rect) + .buttonStyle(.plain) + Group { + Button { player.send(.togglePlayPause) } label: { PlayPauseGlyph(isPlaying: player.isPlaying) } + .accessibilityLabel(player.isPlaying ? "Pause" : "Play") + if placement != .inline { + SkipButton(backward: false) { player.send(.next) } + } + } + .labelStyle(.iconOnly) + .font(.title3) + .frame(width: 40, height: 44) + .tint(.primary) + .disabled(player.current == nil) } - .buttonStyle(.plain) - Group { - Button( - player.isPlaying ? "Pause" as LocalizedStringKey : "Play", - systemImage: player.isPlaying ? "pause.fill" : "play.fill" - ) { - player.send(.togglePlayPause) - } - .contentTransition(.symbolEffect(.replace)) - if placement != .inline { - Button("Next", systemImage: "forward.fill") { player.send(.next) } - } - } - .labelStyle(.iconOnly) - .font(.title3) - .frame(width: 40, height: 44) - .tint(.primary) - .disabled(player.current == nil) + .padding(.leading, 12) + .padding(.trailing, 10) + .frame(height: 44) + .geometryGroup() + .animation(.smooth(duration: 0.3), value: placement) } - .padding(.leading, 12) - .padding(.trailing, 10) } -} +#endif diff --git a/Amphlify/UI/Player/NowPlayingView.swift b/Amphlify/UI/Player/NowPlayingView.swift index 8479621..d708efd 100644 --- a/Amphlify/UI/Player/NowPlayingView.swift +++ b/Amphlify/UI/Player/NowPlayingView.swift @@ -7,11 +7,11 @@ struct NowPlayingView: View { @Environment(PlayerModel.self) private var player @Environment(Router.self) private var router @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var volume = 0.7 @State private var controlsHeight: CGFloat = 0 @State private var controlsRevealedAt = Date() @State private var controlsForcedHidden = false @State private var controlsHidden = false + @State private var queueScroll = QueueScroll() private var hidesControls: Bool { showsLyrics && (controlsForcedHidden || (player.isPlaying && Date().timeIntervalSince(controlsRevealedAt) > 4)) @@ -120,13 +120,13 @@ struct NowPlayingView: View { .overlay(alignment: .topLeading) { Group { if showsLyrics { - LyricsView(bottomInset: controlsHidden ? 0 : stage.overflow, controls: setControls, seeks: !controlsHidden) + LyricsView(bottomInset: controlsHidden ? 0 : stage.overflow, controls: setControls, seeks: true) } else { - QueueView() + QueueView(scroll: queueScroll, header: stage.compactTop + stage.compactSide) } } - .frame(width: stage.size.width, height: showsLyrics ? stage.lyricsHeight : stage.queueHeight) - .offset(y: stage.queueY) + .frame(width: stage.size.width, height: showsLyrics ? stage.lyricsHeight : stage.size.height) + .offset(y: showsLyrics ? stage.queueY : stage.queueY - stage.compactTop - stage.compactSide - 12) .animation(move, value: isCompact) } .clipped() @@ -148,6 +148,8 @@ struct NowPlayingView: View { .opacity(isCompact ? 1 : 0) .animation(isCompact ? .easeInOut(duration: 0.15).delay(0.215) : nil, value: isCompact) .allowsHitTesting(isCompact) + .modifier(QueueFollow(scroll: queueScroll, isActive: showsQueue, height: stage.size.height + stage.overflow)) + .animation(move, value: showsQueue) ArtworkView(artwork: player.current?.artwork, shape: .rounded(isCompact ? 8 : 12), pixelSize: 1024) .shadow(color: .black.opacity(player.isPlaying ? 0.3 : 0.15), radius: 20, y: 10) .scaleEffect(isShrunk ? 0.73 : 1) @@ -156,6 +158,8 @@ struct NowPlayingView: View { .offset(x: stage.artworkX, y: stage.artworkY) .animation(move, value: isCompact) .allowsHitTesting(false) + .modifier(QueueFollow(scroll: queueScroll, isActive: showsQueue, height: stage.size.height + stage.overflow)) + .animation(move, value: showsQueue) } .frame(width: stage.size.width, height: stage.size.height + stage.overflow, alignment: .topLeading) } @@ -220,32 +224,24 @@ struct NowPlayingView: View { Timeline() .modifier(Fold(hidden: hidden, order: 0)) HStack(spacing: 34) { - Button("Previous", systemImage: "backward.fill") { player.send(.previous) }.font(.system(size: 32)) - Button( - player.isPlaying ? "Pause" as LocalizedStringKey : "Play", - systemImage: player.isPlaying ? "pause.fill" : "play.fill" - ) { - player.send(.togglePlayPause) - } - .font(.system(size: 46)) - .contentTransition(.symbolEffect(.replace)) - Button("Next", systemImage: "forward.fill") { player.send(.next) }.font(.system(size: 32)) + SkipButton(backward: true) { player.send(.previous) }.font(.system(size: 30)) + Button { player.send(.togglePlayPause) } label: { PlayPauseGlyph(isPlaying: player.isPlaying) } + .accessibilityLabel(player.isPlaying ? "Pause" : "Play") + .font(.system(size: 42)) + SkipButton(backward: false) { player.send(.next) }.font(.system(size: 30)) } .buttonStyle(TransportButtonStyle()) .disabled(player.current == nil) .padding(.top, 31) .padding(.bottom, 47) .modifier(Fold(hidden: hidden, order: 1)) - HStack(spacing: 10) { - Image(systemName: "speaker.fill") - ScrubBar(value: volume, range: 0 ... 1) { volume = $0 } onCommit: { volume = $0 } - Image(systemName: "speaker.wave.3.fill") + Group { + #if os(iOS) + SystemVolume() + #endif } .font(.footnote) .foregroundStyle(.white.opacity(0.65)) - .accessibilityElement() - .accessibilityLabel("Volume") - .accessibilityValue(Text(volume, format: .percent.precision(.fractionLength(0)))) .modifier(Fold(hidden: hidden, order: 2)) HStack(spacing: 70) { Button("Lyrics", systemImage: "quote.bubble") { router.showsLyrics.toggle() } @@ -265,6 +261,18 @@ struct NowPlayingView: View { } } +private struct QueueFollow: ViewModifier { + let scroll: QueueScroll + let isActive: Bool + let height: CGFloat + + func body(content: Content) -> some View { + content + .offset(y: isActive ? scroll.shift : 0) + .mask(alignment: .topLeading) { Rectangle().frame(width: 2000, height: height).padding(.leading, -100) } + } +} + private struct Fold: ViewModifier { let hidden: Bool let order: Int @@ -285,18 +293,16 @@ private struct Fold: ViewModifier { private struct Timeline: View { @Environment(PlayerModel.self) private var player @State private var scrubbed: Double? + @State private var seekTask: Task? var body: some View { let duration = player.current?.duration ?? 0 - TimelineView(.animation(minimumInterval: 0.2, paused: !player.isPlaying)) { _ in - let position = player.position() + TimelineView(.animation(minimumInterval: 1 / 30, paused: !player.isPlaying || scrubbed != nil)) { _ in + let position = scrubbed ?? player.position() VStack(spacing: -4) { - ScrubBar(value: position, range: 0 ... max(duration, 1)) { scrubbed = $0 } onCommit: { - scrubbed = nil - player.send(.seek(to: $0)) - } + ScrubBar(value: position, range: 0 ... max(duration, 1)) { scrubbed = $0 } onCommit: { seek(to: $0) } HStack { - Text((scrubbed ?? position).clock) + Text(position.clock) Spacer() if let format = player.current?.formats.first { Label(format.badge, systemImage: "waveform") @@ -306,17 +312,31 @@ private struct Timeline: View { .background(.white.opacity(0.12), in: .rect(cornerRadius: 6, style: .continuous)) } Spacer() - Text("-" + max(duration - (scrubbed ?? position), 0).clock) + Text("-" + max(duration - position, 0).clock) } .font(.caption.weight(.semibold).monospacedDigit()) .foregroundStyle(.white.opacity(0.6)) } } + .onChange(of: player.state.current?.id) { + seekTask?.cancel() + scrubbed = nil + } .accessibilityElement() .accessibilityLabel("Track position") .accessibilityValue(Text(player.position().clock)) .accessibilityAdjustableAction { direction in - player.send(.seek(to: player.position() + (direction == .increment ? 15 : -15))) + seek(to: player.position() + (direction == .increment ? 15 : -15)) + } + } + + private func seek(to target: Double) { + scrubbed = min(max(target, 0), player.current?.duration ?? 0) + seekTask?.cancel() + seekTask = Task { + await player.seek(to: target) + guard !Task.isCancelled else { return } + scrubbed = nil } } } @@ -367,9 +387,8 @@ private struct TransportButtonStyle: ButtonStyle { .labelStyle(.iconOnly) .foregroundStyle(.white.opacity(isEnabled ? 1 : 0.35)) .frame(width: 72, height: 72) - .background(.white.opacity(configuration.isPressed ? 0.16 : 0), in: .circle) - .scaleEffect(configuration.isPressed ? 0.86 : 1) - .animation(.spring(duration: 0.25, bounce: 0.4), value: configuration.isPressed) + .contentShape(.circle) + .modifier(PressHighlight(isPressed: configuration.isPressed, fill: .white.opacity(0.12), diameter: 60)) } } @@ -379,6 +398,7 @@ private struct GlyphButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .frame(width: 44, height: 44) + .contentShape(.rect) .opacity(isEnabled ? (configuration.isPressed ? 0.5 : 1) : 0.35) } } diff --git a/Amphlify/UI/Player/PlayPauseGlyph.swift b/Amphlify/UI/Player/PlayPauseGlyph.swift new file mode 100644 index 0000000..567cfdf --- /dev/null +++ b/Amphlify/UI/Player/PlayPauseGlyph.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct PlayPauseGlyph: View { + let isPlaying: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private nonisolated static let shrink = 0.065 + private nonisolated static let settle = 0.75 + + var body: some View { + ZStack { + Image(systemName: "play.fill") + Image(systemName: "pause.fill") + } + .hidden() + .overlay { + let still = reduceMotion + Color.clear.keyframeAnimator(initialValue: Self.settle, trigger: isPlaying) { [isPlaying] _, time in + Self.glyphs(playing: isPlaying, at: time) + } keyframes: { _ in + MoveKeyframe(still ? Self.settle : 0) + LinearKeyframe(Self.settle, duration: still ? 0 : Self.settle) + } + } + .transaction { $0.animation = nil } + .accessibilityHidden(true) + } + + private nonisolated static func glyphs(playing: Bool, at time: Double) -> some View { + let out = min(time / shrink, 1) + let appear = shrink - 0.02 + return ZStack { + symbol(playing ? "play.fill" : "pause.fill") + .scaleEffect(1 - 0.7 * out) + .opacity(max(1 - out * 0.5 - max(time - shrink, 0) / 0.05, 0)) + symbol(playing ? "pause.fill" : "play.fill") + .scaleEffect(0.25 + 0.75 * springCurve(time - appear, omega: 13.2, damping: 0.6)) + .opacity(time < appear ? 0 : 0.55 + 0.45 * min((time - appear) / 0.2, 1)) + } + } + + private nonisolated static func symbol(_ name: String) -> some View { + Image(systemName: name).frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Amphlify/UI/Player/QueueView.swift b/Amphlify/UI/Player/QueueView.swift index e387808..94efa49 100644 --- a/Amphlify/UI/Player/QueueView.swift +++ b/Amphlify/UI/Player/QueueView.swift @@ -1,77 +1,153 @@ import Playback import SwiftUI +@Observable +final class QueueScroll { + var shift: CGFloat = 0 + @ObservationIgnored fileprivate var top: CGFloat = 0 + @ObservationIgnored fileprivate var height: CGFloat = 0 + @ObservationIgnored fileprivate var rest: CGFloat = 0 + + fileprivate var stops: (current: CGFloat, stops: [CGFloat]) { + let history = rest - height + return (top, history > 1 ? [history, rest] : [rest]) + } + + fileprivate func update(top: CGFloat, height: CGFloat, rest: CGFloat) { + self.top = top + self.height = height + self.rest = rest + shift = rest - top + } +} + struct QueueView: View { + let scroll: QueueScroll + let header: CGFloat + @Environment(PlayerModel.self) private var player + @Environment(Router.self) private var router + @State private var position = ScrollPosition(y: 0) + @State private var room: CGFloat = 0 + @State private var settling = true + + fileprivate static let titleHeight: CGFloat = 44 + private static let rowHeight: CGFloat = 56 + + private var rest: CGFloat { + player.state.history.isEmpty ? 0 : Self.titleHeight + CGFloat(player.state.history.count) * Self.rowHeight + } var body: some View { - VStack(spacing: 0) { - HStack(spacing: 12) { - toggle("Shuffle", "shuffle", isOn: player.state.isShuffled) { player.send(.setShuffle(!player.state.isShuffled)) } - toggle("Repeat", player.state.repeatMode == .one ? "repeat.1" : "repeat", isOn: player.state.repeatMode != .off) { - let modes = RepeatMode.allCases - let index = modes.firstIndex(of: player.state.repeatMode) ?? 0 - player.send(.setRepeat(modes[(index + 1) % modes.count])) + let hasHistory = !player.state.history.isEmpty + ScrollView { + LazyVStack(spacing: 0) { + if hasHistory { + Color.clear.frame(height: Self.titleHeight) + ForEach(player.state.history) { row($0, upcoming: false) } } + nowPlaying + upcoming + Color.clear.frame(height: room) } - Text("Playing Next") - .font(.headline) - .foregroundStyle(.white) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 18) - .padding(.bottom, 6) - List { - ForEach(player.state.upcoming) { item in - Button { - player.send(.jump(to: item.id)) - } label: { - HStack(spacing: 12) { - ArtworkView(artwork: item.track.artwork, shape: .rounded(5)).frame(width: 48, height: 48) - VStack(alignment: .leading, spacing: 2) { - Text(item.track.title).foregroundStyle(.white) - Text(item.track.artistName).font(.subheadline).foregroundStyle(.white.opacity(0.6)) - } - .lineLimit(1) - Spacer(minLength: 0) - Image(systemName: "line.3.horizontal").foregroundStyle(.white.opacity(0.3)) - } - .contentShape(.rect) - } - .buttonStyle(.plain) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) - } - .onMove(perform: move) - .onDelete { offsets in - offsets.map { player.state.upcoming[$0].id }.forEach { player.send(.remove($0)) } - } + .modifier(QueueReorder()) + } + .scrollPosition($position) + .scrollTargetBehavior(QueueSnap(scroll: scroll)) + .scrollIndicators(.hidden) + .onScrollGeometryChange(for: QueueGeometry.self) { geometry in + QueueGeometry(top: geometry.visibleRect.minY, height: geometry.visibleRect.height, content: geometry.contentSize.height) + } action: { _, geometry in + scroll.update(top: geometry.top, height: geometry.height, rest: rest) + room = max(geometry.height - (geometry.content - room - rest), 0) + if settling { + settling = abs(geometry.top - rest) > 1 + position.scrollTo(y: rest) } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .scrollIndicators(.hidden) - .overlay { - if player.state.upcoming.isEmpty { - Text("Nothing up next").foregroundStyle(.white.opacity(0.5)) - } + } + .mask { QueueMask(scroll: scroll, hasHistory: hasHistory) } + .overlay(alignment: .top) { + if hasHistory { + HistoryTitle(scroll: scroll) } - .mask { - LinearGradient( - stops: [.init(color: .black, location: 0.9), .init(color: .clear, location: 1)], - startPoint: .top, - endPoint: .bottom - ) + } + .onChange(of: router.showsQueue) { + if !router.showsQueue { + settle() + } + } + .onChange(of: rest) { + if abs(scroll.shift) < 1 { + settle() } } } - private func move(from offsets: IndexSet, to destination: Int) { - let upcoming = player.state.upcoming - guard let from = offsets.first else { return } - var reordered = upcoming - reordered.move(fromOffsets: offsets, toOffset: destination) - guard let landed = reordered.firstIndex(where: { $0.id == upcoming[from].id }) else { return } - player.send(.move(item: upcoming[from].id, after: landed > 0 ? reordered[landed - 1].id : nil)) + private func settle() { + settling = true + position.scrollTo(y: rest) + } + + private var nowPlaying: some View { + HStack(spacing: 12) { + toggle("Shuffle", "shuffle", isOn: player.state.isShuffled) { player.send(.setShuffle(!player.state.isShuffled)) } + toggle("Repeat", player.state.repeatMode == .one ? "repeat.1" : "repeat", isOn: player.state.repeatMode != .off) { + let modes = RepeatMode.allCases + let index = modes.firstIndex(of: player.state.repeatMode) ?? 0 + player.send(.setRepeat(modes[(index + 1) % modes.count])) + } + } + .padding(.top, header + 12) + } + + @ViewBuilder private var upcoming: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Continue Playing").font(.headline).foregroundStyle(.white) + if let album = player.current?.albumTitle.trimmingCharacters(in: .whitespaces), !album.isEmpty { + Text("From \(album)").font(.footnote).foregroundStyle(.white.opacity(0.6)) + } + } + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 18) + .padding(.bottom, 6) + if player.state.upcoming.isEmpty { + Text("Nothing up next").foregroundStyle(.white.opacity(0.5)).padding(.top, 40) + } else if #available(iOS 27.0, macOS 27.0, *) { + ForEach(player.state.upcoming) { row($0, upcoming: true) }.reorderable() + } else { + ForEach(player.state.upcoming) { row($0, upcoming: true) } + } + } + + private func row(_ item: QueueItem, upcoming: Bool) -> some View { + Button { player.send(.jump(to: item.id)) } label: { + HStack(spacing: 12) { + ArtworkView(artwork: item.track.artwork, shape: .rounded(5)).frame(width: 48, height: 48) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(item.track.title).foregroundStyle(.white) + if item.track.isExplicit { + Image(systemName: "e.square.fill").font(.caption).foregroundStyle(.white.opacity(0.55)) + } + } + Text(item.track.artistName).font(.footnote).foregroundStyle(.white.opacity(0.6)) + } + .lineLimit(1) + Spacer(minLength: 0) + if upcoming { + Image(systemName: "line.3.horizontal").foregroundStyle(.white.opacity(0.3)) + } + } + .frame(height: Self.rowHeight) + .contentShape(.rect) + } + .buttonStyle(.plain) + .contextMenu { + if upcoming { + Button("Remove from queue", systemImage: "trash", role: .destructive) { player.send(.remove(item.id)) } + } + } } private func toggle(_ title: LocalizedStringKey, _ systemImage: String, isOn: Bool, action: @escaping () -> Void) -> some View { @@ -88,3 +164,83 @@ struct QueueView: View { .accessibilityAddTraits(isOn ? .isSelected : []) } } + +private struct QueueGeometry: Equatable { + let top: CGFloat + let height: CGFloat + let content: CGFloat +} + +private struct QueueSnap: ScrollTargetBehavior { + let scroll: QueueScroll + + nonisolated func updateTarget(_ target: inout ScrollTarget, context _: TargetContext) { + let (current, stops) = MainActor.assumeIsolated { scroll.stops } + let natural = target.rect.minY + let slack: CGFloat = 40 + if natural < current, let stop = stops.filter({ $0 < current - 1 }).max(), natural < stop - slack { + target.rect.origin.y = stop + } else if natural > current, let stop = stops.filter({ $0 > current + 1 }).min(), natural > stop + slack { + target.rect.origin.y = stop + } + } +} + +private struct HistoryTitle: View { + let scroll: QueueScroll + @Environment(PlayerModel.self) private var player + + var body: some View { + HStack { + Text("History").font(.headline).foregroundStyle(.white) + Spacer() + Button("Clear") { player.send(.clearHistory) } + .buttonStyle(.plain) + .foregroundStyle(.white.opacity(0.55)) + } + .frame(height: QueueView.titleHeight) + .offset(y: min(scroll.shift - QueueView.titleHeight, 0)) + .frame(height: QueueView.titleHeight, alignment: .top) + .clipped() + .opacity(scroll.shift > 0 ? 1 : 0) + .allowsHitTesting(scroll.shift >= QueueView.titleHeight) + } +} + +private struct QueueMask: View { + let scroll: QueueScroll + let hasHistory: Bool + + var body: some View { + VStack(spacing: 0) { + Color.clear.frame(height: hasHistory ? min(max(scroll.shift, 0), QueueView.titleHeight) : 0) + LinearGradient( + stops: [.init(color: .black, location: 0.9), .init(color: .clear, location: 1)], + startPoint: .top, + endPoint: .bottom + ) + } + } +} + +private struct QueueReorder: ViewModifier { + @Environment(PlayerModel.self) private var player + + func body(content: Content) -> some View { + if #available(iOS 27.0, macOS 27.0, *) { + content.reorderContainer(for: QueueItem.self) { difference in + let remaining = player.state.upcoming.map(\.id).filter { !difference.sources.contains($0) } + var after: QueueItem.ID? = switch difference.destination.position { + case let .before(id): remaining.firstIndex(of: id).flatMap { $0 > 0 ? remaining[$0 - 1] : nil } + case .end: remaining.last + } + for source in difference.sources { + player.send(.move(item: source, after: after)) + after = source + } + } + } else { + content + } + } +} diff --git a/Amphlify/UI/Player/RoutePicker.swift b/Amphlify/UI/Player/RoutePicker.swift index 2b77f5c..f7f843c 100644 --- a/Amphlify/UI/Player/RoutePicker.swift +++ b/Amphlify/UI/Player/RoutePicker.swift @@ -1,14 +1,28 @@ import AVKit import SwiftUI -struct RoutePicker: UIViewRepresentable { - func makeUIView(context _: Context) -> AVRoutePickerView { - let view = AVRoutePickerView() - view.tintColor = .white.withAlphaComponent(0.7) - view.activeTintColor = .white - view.prioritizesVideoDevices = false - return view - } +#if os(iOS) + struct RoutePicker: UIViewRepresentable { + func makeUIView(context _: Context) -> AVRoutePickerView { + let view = AVRoutePickerView() + view.tintColor = .white.withAlphaComponent(0.7) + view.activeTintColor = .white + view.prioritizesVideoDevices = false + return view + } - func updateUIView(_: AVRoutePickerView, context _: Context) {} -} + func updateUIView(_: AVRoutePickerView, context _: Context) {} + } +#else + struct RoutePicker: NSViewRepresentable { + func makeNSView(context _: Context) -> AVRoutePickerView { + let view = AVRoutePickerView() + view.isRoutePickerButtonBordered = false + view.setRoutePickerButtonColor(.white.withAlphaComponent(0.7), for: .normal) + view.setRoutePickerButtonColor(.white, for: .active) + return view + } + + func updateNSView(_: AVRoutePickerView, context _: Context) {} + } +#endif diff --git a/Amphlify/UI/Player/ScrubBar.swift b/Amphlify/UI/Player/ScrubBar.swift index 677a144..d1c7f8e 100644 --- a/Amphlify/UI/Player/ScrubBar.swift +++ b/Amphlify/UI/Player/ScrubBar.swift @@ -3,6 +3,8 @@ import SwiftUI struct ScrubBar: View { let value: Double let range: ClosedRange + var thickness: CGFloat = 7 + var height: CGFloat = 30 var onScrub: (Double) -> Void = { _ in } let onCommit: (Double) -> Void @@ -24,7 +26,7 @@ struct ScrubBar: View { Rectangle().fill(.white.opacity(isScrubbing ? 1 : 0.65)).frame(width: proxy.size.width * min(max(fraction, 0), 1)) } .clipShape(.capsule) - .frame(height: isScrubbing ? 14 : 7) + .frame(height: isScrubbing ? thickness * 2 : thickness) .frame(maxHeight: .infinity) .contentShape(.rect) .gesture( @@ -45,7 +47,7 @@ struct ScrubBar: View { } ) } - .frame(height: 30) + .frame(height: height) .scaleEffect(x: isScrubbing && !reduceMotion ? 1.03 : 1, y: 1) .animation(.spring(duration: 0.3), value: isScrubbing) } diff --git a/Amphlify/UI/Player/SkipButton.swift b/Amphlify/UI/Player/SkipButton.swift new file mode 100644 index 0000000..51aab5b --- /dev/null +++ b/Amphlify/UI/Player/SkipButton.swift @@ -0,0 +1,82 @@ +import SwiftUI + +struct SkipButton: View { + let backward: Bool + let action: () -> Void + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var skips = 0 + + var body: some View { + Button { + if !reduceMotion { + skips += 1 + } + action() + } label: { + SkipGlyph(skips: skips).scaleEffect(x: backward ? -1 : 1) + } + .accessibilityLabel(backward ? "Previous" : "Next") + } +} + +private struct SkipGlyph: View { + let skips: Int + + private static let step = 0.455 + private nonisolated static let base = UnitPoint(x: 0.078, y: 0.5) + private nonisolated static let tip = UnitPoint(x: 0.45, y: 0.5) + + var body: some View { + Image(systemName: "forward.fill") + .hidden() + .overlay { + GeometryReader { proxy in + let shift = proxy.size.width * Self.step + Color.clear.keyframeAnimator(initialValue: 0.0, trigger: skips) { _, time in + Self.triangles(at: time, shift: shift) + } keyframes: { _ in + LinearKeyframe(0.75, duration: 0.75) + MoveKeyframe(0) + } + } + } + .transaction { $0.animation = nil } + .accessibilityHidden(true) + } + + private nonisolated static func triangles(at time: Double, shift: CGFloat) -> some View { + let travel = springCurve(time, omega: 12.8, damping: 0.68) + let fade = min(max(time - 0.035, 0) / 0.09, 1) + return ZStack(alignment: .topLeading) { + triangle + .scaleEffect(springCurve(time - 0.035, omega: 12.4, damping: 0.68), anchor: base) + .opacity(time > 0 ? 0.6 + 0.4 * min(time / 0.26, 1) : 1) + .offset(x: max(travel - 1, 0) * shift) + triangle + .offset(x: travel * shift) + triangle + .scaleEffect(1 - fade, anchor: tip) + .opacity(1 - 0.5 * fade) + .offset(x: shift) + } + } + + private nonisolated static var triangle: some View { + Image(systemName: "forward.fill").mask { JunctionMask() } + } +} + +private nonisolated struct JunctionMask: Shape { + func path(in rect: CGRect) -> Path { + let junction = rect.width * 0.53 + let taper = rect.width * 0.04 + return Path { path in + path.move(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: junction - taper, y: rect.minY)) + path.addLine(to: CGPoint(x: junction, y: rect.midY)) + path.addLine(to: CGPoint(x: junction - taper, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.closeSubpath() + } + } +} diff --git a/Amphlify/UI/Player/SpringCurve.swift b/Amphlify/UI/Player/SpringCurve.swift new file mode 100644 index 0000000..bf9997b --- /dev/null +++ b/Amphlify/UI/Player/SpringCurve.swift @@ -0,0 +1,8 @@ +import Foundation + +nonisolated func springCurve(_ time: Double, omega: Double, damping: Double) -> Double { + guard time > 0 else { return 0 } + let damped = omega * (1 - damping * damping).squareRoot() + let decay = exp(-damping * omega * time) + return 1 - decay * (cos(damped * time) + damping * omega / damped * sin(damped * time)) +} diff --git a/Amphlify/UI/Player/SystemVolume.swift b/Amphlify/UI/Player/SystemVolume.swift new file mode 100644 index 0000000..1ec0f6a --- /dev/null +++ b/Amphlify/UI/Player/SystemVolume.swift @@ -0,0 +1,56 @@ +#if os(iOS) + import AVFoundation + import MediaPlayer + import SwiftUI + + struct SystemVolume: View { + @State private var volume = Double(AVAudioSession.sharedInstance().outputVolume) + @State private var dragged: Double? + @State private var output = VolumeOutput() + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "speaker.fill") + ScrubBar(value: dragged ?? volume, range: 0 ... 1) { set($0) } onCommit: { value in + set(value) + dragged = nil + } + Image(systemName: "speaker.wave.3.fill") + } + .background { VolumeHost(output: output).frame(width: 1, height: 1).opacity(0.01) } + .onReceive(AVAudioSession.sharedInstance().publisher(for: \.outputVolume)) { volume = Double($0) } + .accessibilityElement() + .accessibilityLabel("Volume") + .accessibilityValue(Text(volume, format: .percent.precision(.fractionLength(0)))) + .accessibilityAdjustableAction { direction in + set(min(max(volume + (direction == .increment ? 0.0625 : -0.0625), 0), 1)) + } + } + + private func set(_ value: Double) { + dragged = value + volume = value + output.set(value) + } + } + + private final class VolumeOutput { + let view = MPVolumeView(frame: CGRect(x: 0, y: 0, width: 1, height: 1)) + + func set(_ value: Double) { + guard let slider = view.subviews.lazy.compactMap({ $0 as? UISlider }).first else { return } + slider.value = Float(value) + slider.sendActions(for: .valueChanged) + } + } + + private struct VolumeHost: UIViewRepresentable { + let output: VolumeOutput + + func makeUIView(context _: Context) -> MPVolumeView { + output.view + } + + func updateUIView(_: MPVolumeView, context _: Context) {} + } +#endif diff --git a/Amphlify/UI/Radio/RadioView.swift b/Amphlify/UI/Radio/RadioView.swift index bb2177f..92cf7a3 100644 --- a/Amphlify/UI/Radio/RadioView.swift +++ b/Amphlify/UI/Radio/RadioView.swift @@ -38,7 +38,7 @@ struct RadioView: View { } .padding(.vertical, 12) } - .navigationTitle("Radio") + .pageTitle("Radio") .modifier(AccountToolbar()) .task(id: app.revision) { artists = await app.library.artists() @@ -84,7 +84,7 @@ struct GenreCard: View { .foregroundStyle(.white) .multilineTextAlignment(.leading) .padding(12) - .frame(maxWidth: .infinity, minHeight: 104, alignment: .bottomLeading) + .frame(maxWidth: .infinity, minHeight: 104, maxHeight: .infinity, alignment: .bottomLeading) .background { LinearGradient( colors: [ diff --git a/Amphlify/UI/RootView.swift b/Amphlify/UI/RootView.swift index 3123545..ad81ae5 100644 --- a/Amphlify/UI/RootView.swift +++ b/Amphlify/UI/RootView.swift @@ -7,38 +7,44 @@ struct RootView: View { var body: some View { if app.hasSources { - tabs + #if os(iOS) + tabs + #else + MacRootView() + #endif } else { SignInView() } } - private var tabs: some View { - @Bindable var router = router - return TabView(selection: $router.tab) { - Tab("Home", systemImage: "house.fill", value: AppTab.home) { - TabStack(tab: .home) { HomeView() } + #if os(iOS) + private var tabs: some View { + @Bindable var router = router + return TabView(selection: $router.tab) { + Tab("Home", image: "Home", value: AppTab.home) { + TabStack(tab: .home) { HomeView() } + } + Tab("Radio", systemImage: "dot.radiowaves.left.and.right", value: AppTab.radio) { + TabStack(tab: .radio) { RadioView() } + } + Tab("Library", systemImage: "music.note.square.stack.fill", value: AppTab.library) { + TabStack(tab: .library) { LibraryView() } + } + Tab("Search", systemImage: "magnifyingglass", value: AppTab.search) { + TabStack(tab: .search) { SearchView() } + } } - Tab("Radio", systemImage: "dot.radiowaves.left.and.right", value: AppTab.radio) { - TabStack(tab: .radio) { RadioView() } + .tabBarMinimizeBehavior(.onScrollDown) + .tabViewBottomAccessory { + MiniPlayer() + .matchedTransitionSource(id: "player", in: playerSpace) } - Tab("Library", systemImage: "music.note.square.stack.fill", value: AppTab.library) { - TabStack(tab: .library) { LibraryView() } - } - Tab("Search", systemImage: "magnifyingglass", value: AppTab.search) { - TabStack(tab: .search) { SearchView() } + .fullScreenCover(isPresented: $router.showsNowPlaying) { + NowPlayingView() + .navigationTransition(.zoom(sourceID: "player", in: playerSpace)) } } - .tabBarMinimizeBehavior(.onScrollDown) - .tabViewBottomAccessory { - MiniPlayer() - .matchedTransitionSource(id: "player", in: playerSpace) - } - .fullScreenCover(isPresented: $router.showsNowPlaying) { - NowPlayingView() - .navigationTransition(.zoom(sourceID: "player", in: playerSpace)) - } - } + #endif } struct TabStack: View { diff --git a/Amphlify/UI/Search/SearchResultsList.swift b/Amphlify/UI/Search/SearchResultsList.swift new file mode 100644 index 0000000..50e726a --- /dev/null +++ b/Amphlify/UI/Search/SearchResultsList.swift @@ -0,0 +1,154 @@ +#if os(iOS) + import Domain + import Store + import SwiftUI + + struct SearchResultsView: View { + let results: SearchResults + let play: (Track) -> Void + + @State private var scope = Scope.top + + var body: some View { + let active = scopes.contains(scope) ? scope : .top + ScrollView { + LazyVStack(spacing: 0) { + Divider().padding(.leading, Metrics.margin) + ForEach(hits(in: active)) { SearchRow(hit: $0, play: play) } + } + } + .safeAreaBar(edge: .top) { chips(active) } + } + + private var scopes: [Scope] { + Scope.allCases.filter { !hits(in: $0).isEmpty || $0 == .top } + } + + private func hits(in scope: Scope) -> [SearchHit] { + switch scope { + case .top: results.top + case .artists: results.artists.map(SearchHit.artist) + case .albums: results.albums.map(SearchHit.album) + case .songs: results.tracks.map(SearchHit.track) + case .playlists: results.playlists.map(SearchHit.playlist) + } + } + + private func chips(_ active: Scope) -> some View { + ScrollView(.horizontal) { + HStack(spacing: 0) { + ForEach(scopes, id: \.self) { item in + Button { scope = item } label: { + Text(item.title) + .foregroundStyle(item == active ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) + .padding(.horizontal, 13) + .frame(height: 35) + .background { Capsule().fill(.tint).opacity(item == active ? 1 : 0) } + .contentShape(.capsule) + } + .buttonStyle(.plain) + .accessibilityAddTraits(item == active ? .isSelected : []) + } + } + } + .scrollIndicators(.never) + .contentMargins(.horizontal, Metrics.margin, for: .scrollContent) + .padding(.vertical, 6) + } + + private enum Scope: CaseIterable { + case top, artists, albums, songs, playlists + + var title: LocalizedStringKey { + switch self { + case .top: "Top Results" + case .artists: "Artists" + case .albums: "Albums" + case .songs: "Songs" + case .playlists: "Playlists" + } + } + } + } + + struct RecentSearchesView: View { + let hits: [SearchHit] + let play: (Track) -> Void + let clear: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + SectionHeader(title: "Recently Searched") + Spacer() + Button("Clear", action: clear).padding(.trailing, Metrics.margin) + } + LazyVStack(spacing: 0) { + ForEach(hits) { SearchRow(hit: $0, play: play) } + } + } + } + } + + private struct SearchRow: View { + let hit: SearchHit + let play: (Track) -> Void + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + if case let .track(track) = hit { + Button { play(track) } label: { label } + Menu { TrackMenu(track: track) } label: { + Image(systemName: "ellipsis") + .font(.body.weight(.semibold)) + .frame(width: 44, height: 44) + .contentShape(.rect) + } + .tint(.primary) + .accessibilityLabel("More") + } else if let route = hit.route { + NavigationLink(value: route) { + HStack(spacing: 0) { + label + Image(systemName: "chevron.right") + .font(.body.weight(.semibold)) + .foregroundStyle(.tertiary) + .frame(width: 44) + } + } + } + } + .buttonStyle(.plain) + .padding(.leading, Metrics.margin) + .padding(.trailing, 8) + .frame(height: 75) + .contextMenu { + if case let .track(track) = hit { + TrackMenu(track: track) + } + } + Divider().padding(.leading, Metrics.margin + 68) + } + } + + private var label: some View { + HStack(spacing: 12) { + ArtworkView(artwork: hit.artwork, shape: hit.shape).frame(width: 56, height: 56) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(hit.title) + if hit.isExplicit { + Image(systemName: "e.square.fill").font(.caption).foregroundStyle(.secondary) + } + } + hit.subtitle.font(.subheadline).foregroundStyle(.secondary) + } + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: .infinity) + .contentShape(.rect) + } + } +#endif diff --git a/Amphlify/UI/Search/SearchView.swift b/Amphlify/UI/Search/SearchView.swift index 64d3f0d..8ef9ad8 100644 --- a/Amphlify/UI/Search/SearchView.swift +++ b/Amphlify/UI/Search/SearchView.swift @@ -5,78 +5,188 @@ import SwiftUI struct SearchView: View { @Environment(AppModel.self) private var app @Environment(PlayerModel.self) private var player - @State private var query = "" + @Environment(Router.self) private var router + @FocusState private var searching: Bool + @AppStorage("recentSearches") private var recentData = Data() + @State private var query = SearchView.launchQuery @State private var results = SearchResults() @State private var genres: [Genre] = [] var body: some View { - ScrollView { - if query.trimmingCharacters(in: .whitespaces).isEmpty { - browse - } else if results.isEmpty { - ContentUnavailableView.search(text: query).padding(.top, 80) - } else { - found + content + .modifier(SearchTitle()) + .modifier(AccountToolbar()) + .searchable(text: $query, placement: searchPlacement, prompt: "Artists, Songs, Albums and More") + .searchFocused($searching) + .onChange(of: router.searchRequests, initial: true) { + if router.searchRequests > 0 { + searching = true + } } + .onChange(of: router.path(for: .search)) { _, path in + if path.count == 1, let hit = path.first.flatMap(hit(for:)) { + remember(hit) + } + } + .task(id: query) { + results = await app.library.search(query) + } + .task(id: app.revision) { genres = await app.library.genres() } + } + + @ViewBuilder private var content: some View { + if query.trimmingCharacters(in: .whitespaces).isEmpty { + ScrollView { + VStack(alignment: .leading, spacing: Layout.sectionSpacing) { + if !recents.isEmpty { + RecentSearchesView(hits: recents, play: play) { recentData = Data() } + } + browse + } + .padding(.vertical, Layout.verticalPadding) + } + } else if results.isEmpty { + ContentUnavailableView.search(text: query) + } else { + SearchResultsView(results: results, play: play) } - .navigationTitle("Search") - .modifier(AccountToolbar()) - .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Artists, Songs, Albums and More") - .task(id: query) { - results = await app.library.search(query) - } - .task(id: app.revision) { genres = await app.library.genres() } } private var browse: some View { VStack(alignment: .leading, spacing: 12) { SectionHeader(title: "Browse Categories") - LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 12) { + LazyVGrid(columns: [GridItem(.adaptive(minimum: Layout.genreWidth), spacing: Layout.spacing)], spacing: Layout.spacing) { ForEach(Array(genres.enumerated()), id: \.element) { index, genre in - NavigationLink(value: Route.genre(genre.name)) { GenreCard(name: genre.name, index: index) }.buttonStyle(.plain) + NavigationLink(value: Route.genre(genre.name)) { genreCard(genre.name, index: index) }.buttonStyle(.plain) } } .padding(.horizontal, Metrics.margin) } - .padding(.vertical, 12) } - private var found: some View { - LazyVStack(spacing: 0) { - ForEach(results.artists) { artist in - link(.artist(artist.id), artwork: artist.artwork, isCircle: true, title: artist.name, subtitle: Text("Artist")) - } - ForEach(results.albums) { album in - link(.album(album.id), artwork: album.artwork, title: album.title, subtitle: Text("Album · \(album.artistName)")) - } - ForEach(results.playlists) { playlist in - link(.playlist(playlist.id), artwork: playlist.artwork, title: playlist.name, subtitle: Text("Playlist")) - } - ForEach(Array(results.tracks.enumerated()), id: \.offset) { index, track in - TrackRow(track: track) { player.play(results.tracks, startingAt: index) } - Divider().padding(.leading, Metrics.margin + 60) - } + private func genreCard(_ name: String, index: Int) -> some View { + #if os(macOS) + Color.clear.aspectRatio(1.8, contentMode: .fit).overlay { GenreCard(name: name, index: index) } + #else + GenreCard(name: name, index: index) + #endif + } + + private var recents: [SearchHit] { + (try? JSONDecoder().decode([SearchHit].self, from: recentData)) ?? [] + } + + private func remember(_ hit: SearchHit) { + let hits = [hit] + recents.filter { $0.id != hit.id } + recentData = (try? JSONEncoder().encode(Array(hits.prefix(12)))) ?? Data() + } + + private func hit(for route: Route) -> SearchHit? { + switch route { + case let .artist(id): results.artists.first { $0.id == id }.map(SearchHit.artist) + case let .album(id): results.albums.first { $0.id == id }.map(SearchHit.album) + case let .playlist(id): results.playlists.first { $0.id == id }.map(SearchHit.playlist) + default: nil } } - private func link(_ route: Route, artwork: EntityID?, isCircle: Bool = false, title: String, subtitle: Text) -> some View { - VStack(spacing: 0) { - NavigationLink(value: route) { - HStack(spacing: 12) { - ArtworkView(artwork: artwork, shape: isCircle ? .circle : .rounded(5)).frame(width: 48, height: 48) - VStack(alignment: .leading, spacing: 2) { - Text(title).lineLimit(1) - subtitle.font(.subheadline).foregroundStyle(.secondary).lineLimit(1) - } - Spacer() - Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(.tertiary) - } - .padding(.horizontal, Metrics.margin) - .frame(minHeight: 60) - .contentShape(.rect) - } - .buttonStyle(.plain) - Divider().padding(.leading, Metrics.margin + 60) + private func play(_ track: Track) { + remember(.track(track)) + let tracks = results.tracks.contains { $0.id == track.id } ? results.tracks : [track] + player.play(tracks, startingAt: tracks.firstIndex { $0.id == track.id } ?? 0) + } +} + +private enum Layout { + #if os(macOS) + static let genreWidth: CGFloat = 190 + static let spacing: CGFloat = 20 + static let sectionSpacing: CGFloat = 26 + static let verticalPadding: CGFloat = 16 + #else + static let genreWidth: CGFloat = 150 + static let spacing: CGFloat = 12 + static let sectionSpacing: CGFloat = 24 + static let verticalPadding: CGFloat = 12 + #endif +} + +extension SearchHit { + var title: String { + switch self { + case let .artist(artist): artist.name + case let .album(album): album.title + case let .track(track): track.title + case let .playlist(playlist): playlist.name } + } + + var subtitle: Text { + switch self { + case .artist: Text("Artist") + case let .album(album): Text("Album · \(album.artistName)") + case let .track(track): Text("Song · \(track.artistName)") + case .playlist: Text("Playlist") + } + } + + var artwork: EntityID? { + switch self { + case let .artist(artist): artist.artwork + case let .album(album): album.artwork + case let .track(track): track.artwork + case let .playlist(playlist): playlist.artwork + } + } + + var route: Route? { + switch self { + case let .artist(artist): .artist(artist.id) + case let .album(album): .album(album.id) + case .track: nil + case let .playlist(playlist): .playlist(playlist.id) + } + } + + var shape: ArtworkView.Shape { + if case .artist = self { + return .circle + } + return .rounded(4) + } + + var isExplicit: Bool { + if case let .track(track) = self { + return track.isExplicit + } + return false + } +} + +private struct SearchTitle: ViewModifier { + func body(content: Content) -> some View { + #if os(iOS) + content.navigationTitle("Search") + #else + content.toolbar(removing: .title) + #endif + } +} + +private extension SearchView { + static var launchQuery: String { + #if DEBUG + UserDefaults.standard.string(forKey: "query") ?? "" + #else + "" + #endif + } + + var searchPlacement: SearchFieldPlacement { + #if os(iOS) + .navigationBarDrawer(displayMode: .always) + #else + .toolbarPrincipal + #endif } } diff --git a/Amphlify/UI/SignInView.swift b/Amphlify/UI/SignInView.swift index 2b83f30..f8fdd98 100644 --- a/Amphlify/UI/SignInView.swift +++ b/Amphlify/UI/SignInView.swift @@ -22,8 +22,7 @@ struct SignInView: View { Form { Section { TextField("Server", text: $server) - .textContentType(.URL) - .keyboardType(.URL) + .modifier(URLField()) .focused($focus, equals: .server) .submitLabel(.next) .onSubmit { focus = .username } @@ -42,8 +41,7 @@ struct SignInView: View { Text(failure).foregroundStyle(.red) } } - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + .plainTextInput() Section { Button(action: signIn) { ZStack { @@ -85,3 +83,13 @@ struct SignInView: View { } } } + +private struct URLField: ViewModifier { + func body(content: Content) -> some View { + #if os(iOS) + content.textContentType(.URL).keyboardType(.URL) + #else + content + #endif + } +} diff --git a/Config/Amphlify.entitlements b/Config/Amphlify.entitlements new file mode 100644 index 0000000..ee95ab7 --- /dev/null +++ b/Config/Amphlify.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/Config/Info.plist b/Config/Info.plist index 435eb45..60dd64e 100644 --- a/Config/Info.plist +++ b/Config/Info.plist @@ -4,6 +4,8 @@ CADisableMinimumFrameDurationOnPhone + LSMultipleInstancesProhibited + UIBackgroundModes audio diff --git a/Makefile b/Makefile index 580c313..ca3f4a0 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,7 @@ check: swiftformat --lint . swiftlint --strict --quiet $(XCODEBUILD) -destination 'generic/platform=iOS Simulator' build + $(XCODEBUILD) -destination 'platform=macOS' build test: swift test --package-path $(KIT) --quiet diff --git a/Packages/AmphlifyKit/Sources/Playback/AVFoundationEngine.swift b/Packages/AmphlifyKit/Sources/Playback/AVFoundationEngine.swift index 0e43850..fa5341e 100644 --- a/Packages/AmphlifyKit/Sources/Playback/AVFoundationEngine.swift +++ b/Packages/AmphlifyKit/Sources/Playback/AVFoundationEngine.swift @@ -3,7 +3,7 @@ import Domain import Foundation - public actor AVFoundationEngine: OutputEngine { + public actor AVFoundationEngine: OutputEngine, PlaybackTuning { public typealias Resolve = @Sendable (Track) async throws -> StreamRequest private struct Slot { @@ -22,6 +22,7 @@ private var prepared: Slot? private var adoptable = false private var wantsPlaying = false + public private(set) var matchesSampleRate = false private var sessionActive = false private var loadGeneration = 0 private var prepareGeneration = 0 @@ -58,7 +59,10 @@ current = prepared self.prepared = nil } else { - guard let slot = await makeSlot(item), generation == loadGeneration else { return } + guard let request = await resolveStream(item), generation == loadGeneration else { return } + await switchRate(for: item, request: request) + guard generation == loadGeneration else { return } + let slot = makeSlot(item, request) player.removeAllItems() prepared = nil player.insert(slot.playerItem, after: nil) @@ -80,11 +84,20 @@ self.prepared = nil } guard let item, current != nil else { return } - guard let slot = await makeSlot(item), generation == prepareGeneration, current != nil else { return } + guard let request = await resolveStream(item), generation == prepareGeneration, current != nil else { return } + guard rateSwitch(for: item, request: request) == nil else { return } + let slot = makeSlot(item, request) player.insert(slot.playerItem, after: nil) prepared = slot } + public func setMatchesSampleRate(_ isOn: Bool) { + matchesSampleRate = isOn + guard isOn, let prepared, rateSwitch(for: prepared.item, request: nil) != nil else { return } + player.remove(prepared.playerItem) + self.prepared = nil + } + public func play() async { wantsPlaying = true if !sessionActive { @@ -104,6 +117,10 @@ reportAnchor() } + public func setVolume(_ volume: Double) { + player.volume = Float(min(max(volume, 0), 1)) + } + public func stop() { wantsPlaying = false player.pause() @@ -113,22 +130,44 @@ adoptable = false } - private func makeSlot(_ item: QueueItem) async -> Slot? { + private func resolveStream(_ item: QueueItem) async -> StreamRequest? { do { - let request = try await resolve(item.track) - let playerItem = makePlayerItem(request) - let observation = playerItem.observe(\.status, options: [.new]) { @Sendable [weak self] playerItem, _ in - guard playerItem.status == .failed else { return } - let id = ObjectIdentifier(playerItem) - Task { await self?.itemFailed(id) } - } - return Slot(item: item, playerItem: playerItem, statusObservation: observation) + return try await resolve(item.track) } catch { continuation.yield(.failed(item.id)) return nil } } + private func makeSlot(_ item: QueueItem, _ request: StreamRequest) -> Slot { + let playerItem = makePlayerItem(request) + let observation = playerItem.observe(\.status, options: [.new]) { @Sendable [weak self] playerItem, _ in + guard playerItem.status == .failed else { return } + let id = ObjectIdentifier(playerItem) + Task { await self?.itemFailed(id) } + } + return Slot(item: item, playerItem: playerItem, statusObservation: observation) + } + + private func rateSwitch(for item: QueueItem, request: StreamRequest?) -> Double? { + #if os(macOS) + let format = request?.format ?? item.track.formats.first + return SampleRateMatcher(isEnabled: matchesSampleRate).targetRate(for: format, on: OutputDevice.current()) + #else + return nil + #endif + } + + private func switchRate(for item: QueueItem, request: StreamRequest) async { + #if os(macOS) + guard let rate = rateSwitch(for: item, request: request) else { return } + player.pause() + player.removeAllItems() + prepared = nil + try? await OutputDevice.setNominalRate(rate) + #endif + } + private func makePlayerItem(_ request: StreamRequest) -> AVPlayerItem { var options: [String: any Sendable] = [AVURLAssetPreferPreciseDurationAndTimingKey: true] if !request.resource.headers.isEmpty { diff --git a/Packages/AmphlifyKit/Sources/Playback/OutputDevice.swift b/Packages/AmphlifyKit/Sources/Playback/OutputDevice.swift new file mode 100644 index 0000000..72ae517 --- /dev/null +++ b/Packages/AmphlifyKit/Sources/Playback/OutputDevice.swift @@ -0,0 +1,127 @@ +#if os(macOS) + import CoreAudio + import Foundation + + public enum OutputDeviceError: Error { + case noDevice, unsupportedRate, writeFailed, timedOut + } + + public enum OutputDevice { + private static let pollStep: Duration = .milliseconds(20) + private static let pollLimit = 50 + private static let settle: Duration = .milliseconds(100) + + public static func current() -> OutputDeviceInfo? { + guard let id = defaultDevice() else { return nil } + return OutputDeviceInfo( + id: id, + name: read(kAudioObjectPropertyName, from: id, as: CFString.self).map { $0 as String } ?? "", + nominalRate: read(kAudioDevicePropertyNominalSampleRate, from: id, as: Float64.self) ?? 0, + availableRates: availableRates(of: id) + ) + } + + public static func setNominalRate(_ rate: Double) async throws { + guard let device = current() else { throw OutputDeviceError.noDevice } + guard device.nominalRate != rate else { return } + guard device.availableRates.contains(rate) else { throw OutputDeviceError.unsupportedRate } + var address = address(kAudioDevicePropertyNominalSampleRate) + var value = rate + let size = UInt32(MemoryLayout.size) + guard AudioObjectSetPropertyData(device.id, &address, 0, nil, size, &value) == noErr else { + throw OutputDeviceError.writeFailed + } + for _ in 0 ..< pollLimit { + if read(kAudioDevicePropertyNominalSampleRate, from: device.id, as: Float64.self) == rate { + try await Task.sleep(for: settle) + return + } + try await Task.sleep(for: pollStep) + } + throw OutputDeviceError.timedOut + } + + public static func changes() -> AsyncStream { + AsyncStream { continuation in + continuation.yield(current()) + let listener = Listener { continuation.yield(current()) } + continuation.onTermination = { _ in listener.stop() } + } + } + + fileprivate static func defaultDevice() -> AudioDeviceID? { + let system = AudioObjectID(kAudioObjectSystemObject) + let id = read(kAudioHardwarePropertyDefaultOutputDevice, from: system, as: AudioDeviceID.self) + return id.flatMap { $0 == kAudioObjectUnknown ? nil : $0 } + } + + fileprivate static func address(_ selector: AudioObjectPropertySelector) -> AudioObjectPropertyAddress { + AudioObjectPropertyAddress( + mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + } + + private static func read(_ selector: AudioObjectPropertySelector, from object: AudioObjectID, as _: T.Type) -> T? { + var address = address(selector) + var size = UInt32(MemoryLayout.size) + let buffer = UnsafeMutablePointer.allocate(capacity: 1) + defer { buffer.deallocate() } + guard AudioObjectGetPropertyData(object, &address, 0, nil, &size, buffer) == noErr else { return nil } + return buffer.move() + } + + private static func availableRates(of device: AudioDeviceID) -> [Double] { + var address = address(kAudioDevicePropertyAvailableNominalSampleRates) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr else { return [] } + let count = Int(size) / MemoryLayout.stride + var ranges = [AudioValueRange](repeating: AudioValueRange(), count: count) + guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &ranges) == noErr else { return [] } + return Array(Set(ranges.flatMap { [$0.mMinimum, $0.mMaximum] })).sorted() + } + } + + private final class Listener: @unchecked Sendable { + private let queue = DispatchQueue(label: "Amphlify.OutputDevice") + private let system = AudioObjectID(kAudioObjectSystemObject) + private var device: AudioDeviceID = kAudioObjectUnknown + private var systemAddress = OutputDevice.address(kAudioHardwarePropertyDefaultOutputDevice) + private var rateAddress = OutputDevice.address(kAudioDevicePropertyNominalSampleRate) + private var onSystemChange: AudioObjectPropertyListenerBlock = { _, _ in } + private var onRateChange: AudioObjectPropertyListenerBlock = { _, _ in } + + init(onChange: @escaping @Sendable () -> Void) { + onRateChange = { _, _ in onChange() } + onSystemChange = { [weak self] _, _ in + self?.follow() + onChange() + } + queue.sync { + AudioObjectAddPropertyListenerBlock(system, &systemAddress, queue, onSystemChange) + follow() + } + } + + func stop() { + queue.sync { + AudioObjectRemovePropertyListenerBlock(system, &systemAddress, queue, onSystemChange) + unfollow() + } + } + + private func follow() { + unfollow() + device = OutputDevice.defaultDevice() ?? kAudioObjectUnknown + guard device != kAudioObjectUnknown else { return } + AudioObjectAddPropertyListenerBlock(device, &rateAddress, queue, onRateChange) + } + + private func unfollow() { + guard device != kAudioObjectUnknown else { return } + AudioObjectRemovePropertyListenerBlock(device, &rateAddress, queue, onRateChange) + device = kAudioObjectUnknown + } + } +#endif diff --git a/Packages/AmphlifyKit/Sources/Playback/PlaybackModel.swift b/Packages/AmphlifyKit/Sources/Playback/PlaybackModel.swift index 707ae14..b32af57 100644 --- a/Packages/AmphlifyKit/Sources/Playback/PlaybackModel.swift +++ b/Packages/AmphlifyKit/Sources/Playback/PlaybackModel.swift @@ -29,8 +29,10 @@ public enum PlaybackCommand: Codable, Sendable { case move(item: QueueItem.ID, after: QueueItem.ID?) case remove(QueueItem.ID) case clearUpcoming + case clearHistory case setShuffle(Bool) case setRepeat(RepeatMode) + case setVolume(Double) } public struct PlaybackAnchor: Hashable, Sendable { @@ -63,6 +65,7 @@ public struct PlaybackState: Hashable, Sendable { public var anchor = PlaybackAnchor(position: 0, instant: .now, rate: 0) public var isShuffled = false public var repeatMode: RepeatMode = .off + public var volume: Double = 1 public init() {} } @@ -81,8 +84,10 @@ public protocol OutputEngine: Sendable { func pause() async func seek(to position: TimeInterval) async func stop() async + func setVolume(_ volume: Double) async } public extension OutputEngine { func prepare(_: QueueItem?) async {} + func setVolume(_: Double) async {} } diff --git a/Packages/AmphlifyKit/Sources/Playback/PlaybackSession.swift b/Packages/AmphlifyKit/Sources/Playback/PlaybackSession.swift index cbc4190..5895a5e 100644 --- a/Packages/AmphlifyKit/Sources/Playback/PlaybackSession.swift +++ b/Packages/AmphlifyKit/Sources/Playback/PlaybackSession.swift @@ -52,8 +52,13 @@ public actor PlaybackSession { case let .move(item, after): move(item, after: after) case let .remove(id): state.upcoming.removeAll { $0.id == id } case .clearUpcoming: state.upcoming.removeAll() + case .clearHistory: state.history.removeAll() case let .setShuffle(isOn): setShuffle(isOn) case let .setRepeat(mode): state.repeatMode = mode + case let .setVolume(volume): + guard volume.isFinite else { return } + state.volume = min(max(volume, 0), 1) + await engine.setVolume(state.volume) } publish() await syncPrepared() @@ -142,12 +147,14 @@ public actor PlaybackSession { private func resume() async { guard state.current != nil, state.status != .playing || state.anchor.rate == 0 else { return } anchor(position, playing: true) + publish() await engine.play() } private func pause() async { guard state.status == .playing else { return } anchor(position, playing: false) + publish() await engine.pause() } @@ -155,6 +162,7 @@ public actor PlaybackSession { guard let current = state.current else { return } let clamped = min(max(target, 0), current.track.duration) anchor(clamped, playing: state.status == .playing) + publish() await engine.seek(to: clamped) } @@ -165,10 +173,15 @@ public actor PlaybackSession { await engine.stop() return } + state.anchor = PlaybackAnchor(position: position, instant: now(), rate: 0) + state.status = playing ? .playing : .paused + publish() await engine.load(current, at: position) anchor(position, playing: playing) if playing { await engine.play() + } else { + await engine.pause() } } diff --git a/Packages/AmphlifyKit/Sources/Playback/SampleRateMatcher.swift b/Packages/AmphlifyKit/Sources/Playback/SampleRateMatcher.swift new file mode 100644 index 0000000..8f52277 --- /dev/null +++ b/Packages/AmphlifyKit/Sources/Playback/SampleRateMatcher.swift @@ -0,0 +1,34 @@ +import Domain +import Foundation + +public struct OutputDeviceInfo: Hashable, Sendable { + public var id: UInt32 + public var name: String + public var nominalRate: Double + public var availableRates: [Double] + + public init(id: UInt32, name: String, nominalRate: Double, availableRates: [Double]) { + self.id = id + self.name = name + self.nominalRate = nominalRate + self.availableRates = availableRates + } +} + +public struct SampleRateMatcher: Sendable { + public var isEnabled: Bool + + public init(isEnabled: Bool) { + self.isEnabled = isEnabled + } + + public func targetRate(for format: AudioFormat?, on device: OutputDeviceInfo?) -> Double? { + guard isEnabled, let device, let rate = format?.sampleRate.map(Double.init) else { return nil } + guard rate != device.nominalRate, device.availableRates.contains(rate) else { return nil } + return rate + } +} + +public protocol PlaybackTuning: Sendable { + func setMatchesSampleRate(_ isOn: Bool) async +} diff --git a/Packages/AmphlifyKit/Sources/Store/Library.swift b/Packages/AmphlifyKit/Sources/Store/Library.swift index 6c4216b..736fee9 100644 --- a/Packages/AmphlifyKit/Sources/Store/Library.swift +++ b/Packages/AmphlifyKit/Sources/Store/Library.swift @@ -5,7 +5,24 @@ public enum AlbumSort: String, CaseIterable, Sendable { case recentlyAdded, title, artist, year } +public enum SearchHit: Identifiable, Hashable, Codable, Sendable { + case artist(Artist) + case album(Album) + case track(Track) + case playlist(Playlist) + + public var id: EntityID { + switch self { + case let .artist(artist): artist.id + case let .album(album): album.id + case let .track(track): track.id + case let .playlist(playlist): playlist.id + } + } +} + public struct SearchResults: Sendable { + public var top: [SearchHit] = [] public var artists: [Artist] = [] public var albums: [Album] = [] public var tracks: [Track] = [] diff --git a/Packages/AmphlifyKit/Sources/Store/MemoryLibrary.swift b/Packages/AmphlifyKit/Sources/Store/MemoryLibrary.swift index f602b0a..dac3e71 100644 --- a/Packages/AmphlifyKit/Sources/Store/MemoryLibrary.swift +++ b/Packages/AmphlifyKit/Sources/Store/MemoryLibrary.swift @@ -115,7 +115,7 @@ public actor MemoryLibrary: Library { public func tracks(byArtist id: EntityID) -> [Track] { tracksByID.values.filter { $0.artistID == id } - .sorted { (annotations[$0.id]?.playCount ?? 0, $1.title) > (annotations[$1.id]?.playCount ?? 0, $0.title) } + .sorted { (plays($0), $1.title) > (plays($1), $0.title) } } public func tracks(inGenre genre: String) -> [Track] { @@ -181,14 +181,59 @@ public actor MemoryLibrary: Library { public func search(_ query: String) -> SearchResults { let needle = query.trimmingCharacters(in: .whitespaces) guard !needle.isEmpty else { return SearchResults() } - func matches(_ text: String) -> Bool { - text.range(of: needle, options: [.caseInsensitive, .diacriticInsensitive]) != nil + func named(_ name: String) -> Int? { + relevance(of: needle, in: name) } + func credited(_ title: String, _ artist: String) -> Int? { + [named(title), named(artist).map { $0 + 1 }].compactMap(\.self).min() + } + let popular = tracks().sorted { (plays($0), $1.title) > (plays($1), $0.title) } + let artists = ranked(artists()) { named($0.name) } + let tracks = Array(ranked(popular) { credited($0.title, $0.artistName) }.prefix(50)) + let albums = ranked(albums(sortedBy: .title)) { credited($0.title, $0.artistName) } + let playlists = ranked(playlists()) { named($0.name) } + let candidates = artists.prefix(3).map { $0.map(SearchHit.artist) } + + tracks.prefix(12).map { $0.map(SearchHit.track) } + + albums.prefix(4).map { $0.map(SearchHit.album) } + + playlists.prefix(3).map { $0.map(SearchHit.playlist) } var results = SearchResults() - results.artists = artists().filter { matches($0.name) } - results.albums = albums(sortedBy: .title).filter { matches($0.title) } - results.tracks = Array(tracks().filter { matches($0.title) }.prefix(50)) - results.playlists = playlists().filter { matches($0.name) } + results.top = ranked(candidates) { $0.score }.map(\.item.item) + results.artists = artists.map(\.item) + results.albums = albums.map(\.item) + results.tracks = tracks.map(\.item) + results.playlists = playlists.map(\.item) return results } + + private func plays(_ track: Track) -> Int { + annotations[track.id]?.playCount ?? 0 + } +} + +private struct Ranked { + let item: Item + let score: Int + + func map(_ transform: (Item) -> Other) -> Ranked { + Ranked(item: transform(item), score: score) + } +} + +private func ranked(_ items: [Item], by score: (Item) -> Int?) -> [Ranked] { + items.enumerated() + .compactMap { offset, item in score(item).map { (Ranked(item: item, score: $0), offset) } } + .sorted { ($0.0.score, $0.1) < ($1.0.score, $1.1) } + .map(\.0) +} + +private func relevance(of needle: String, in text: String) -> Int? { + let options: String.CompareOptions = [.caseInsensitive, .diacriticInsensitive] + guard let range = text.range(of: needle, options: options) else { return nil } + if text.compare(needle, options: options) == .orderedSame { + return 0 + } + if range.lowerBound == text.startIndex { + return 1 + } + return text[text.index(before: range.lowerBound)].isLetter ? 3 : 2 } diff --git a/Packages/AmphlifyKit/Tests/PlaybackTests/PlaybackSessionTests.swift b/Packages/AmphlifyKit/Tests/PlaybackTests/PlaybackSessionTests.swift index a233615..b73b862 100644 --- a/Packages/AmphlifyKit/Tests/PlaybackTests/PlaybackSessionTests.swift +++ b/Packages/AmphlifyKit/Tests/PlaybackTests/PlaybackSessionTests.swift @@ -6,8 +6,10 @@ import Testing actor RecordingEngine: OutputEngine { nonisolated let events: AsyncStream nonisolated let continuation: AsyncStream.Continuation + private(set) var volumes: [Double] = [] private(set) var loaded: [QueueItem.ID] = [] private(set) var prepared: [QueueItem.ID?] = [] + private(set) var isPlaying = false init() { (events, continuation) = AsyncStream.makeStream() @@ -21,8 +23,18 @@ actor RecordingEngine: OutputEngine { prepared.append(item?.id) } - func play() {} - func pause() {} + func setVolume(_ volume: Double) { + volumes.append(volume) + } + + func play() { + isPlaying = true + } + + func pause() { + isPlaying = false + } + func seek(to _: TimeInterval) {} func stop() {} } @@ -183,3 +195,37 @@ private func tracks(_ count: Int) -> [Track] { #expect(state.status == .paused && state.anchor.rate == 0) #expect(await engine.loaded.count == 2) } + +@Test func volumeReachesEngineAndSurvivesQueueChanges() async { + let engine = RecordingEngine() + let session = PlaybackSession(engine: engine) + await session.send(.setVolume(0.35)) + await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false)) + await session.send(.next) + #expect(await session.snapshot.volume == 0.35) + #expect(await engine.volumes == [0.35]) + await session.send(.setVolume(-1)) + await session.send(.setVolume(2)) + await session.send(.setVolume(.nan)) + #expect(await engine.volumes == [0.35, 0, 1]) + #expect(await session.snapshot.volume == 1) +} + +@Test func skippingPastTheEndStopsTheEngine() async { + let engine = RecordingEngine() + let session = PlaybackSession(engine: engine) + await session.send(.setQueue(tracks: tracks(1), startIndex: 0, shuffled: false)) + #expect(await engine.isPlaying) + await session.send(.next) + #expect(await session.snapshot.status == .paused) + #expect(await !engine.isPlaying) +} + +@Test func clearingHistoryKeepsCurrentAndUpcoming() async { + let session = PlaybackSession(engine: RecordingEngine()) + await session.send(.setQueue(tracks: tracks(4), startIndex: 2, shuffled: false)) + await session.send(.clearHistory) + let state = await session.snapshot + #expect(state.history.isEmpty) + #expect(state.current?.track.title == "Track 2" && state.upcoming.count == 1) +} diff --git a/Packages/AmphlifyKit/Tests/PlaybackTests/SampleRateMatcherTests.swift b/Packages/AmphlifyKit/Tests/PlaybackTests/SampleRateMatcherTests.swift new file mode 100644 index 0000000..226adf8 --- /dev/null +++ b/Packages/AmphlifyKit/Tests/PlaybackTests/SampleRateMatcherTests.swift @@ -0,0 +1,34 @@ +import Domain +import Foundation +@testable import Playback +import Testing + +struct SampleRateMatcherTests { + private let device = OutputDeviceInfo(id: 1, name: "Fake", nominalRate: 48000, availableRates: [44100, 48000, 96000]) + private let flac = AudioFormat(codec: .flac, sampleRate: 44100, bitDepth: 16) + + @Test func settingOffLeavesDeviceAlone() { + #expect(SampleRateMatcher(isEnabled: false).targetRate(for: flac, on: device) == nil) + } + + @Test func sameRateNeedsNoSwitch() { + let format = AudioFormat(codec: .flac, sampleRate: 48000) + #expect(SampleRateMatcher(isEnabled: true).targetRate(for: format, on: device) == nil) + } + + @Test func unavailableRateIsIgnored() { + let format = AudioFormat(codec: .flac, sampleRate: 192_000) + #expect(SampleRateMatcher(isEnabled: true).targetRate(for: format, on: device) == nil) + } + + @Test func unknownRateOrDeviceIsIgnored() { + let matcher = SampleRateMatcher(isEnabled: true) + #expect(matcher.targetRate(for: AudioFormat(codec: .mp3), on: device) == nil) + #expect(matcher.targetRate(for: nil, on: device) == nil) + #expect(matcher.targetRate(for: flac, on: nil) == nil) + } + + @Test func availableRateIsChosenExactly() { + #expect(SampleRateMatcher(isEnabled: true).targetRate(for: flac, on: device) == 44100) + } +} diff --git a/Packages/AmphlifyKit/Tests/StoreTests/SearchTests.swift b/Packages/AmphlifyKit/Tests/StoreTests/SearchTests.swift new file mode 100644 index 0000000..d9c1feb --- /dev/null +++ b/Packages/AmphlifyKit/Tests/StoreTests/SearchTests.swift @@ -0,0 +1,37 @@ +import Domain +import Store +import Testing + +@Test func searchRanksExactArtistFirstAndFindsTheirSongs() async { + let source = SourceID() + func id(_ remote: String) -> EntityID { + EntityID(source: source, remote: remote) + } + func track(_ remote: String, _ title: String, by artist: String, _ artistID: EntityID?) -> Change { + .upsert(.track(Track( + id: id(remote), + title: title, + albumID: nil, + albumTitle: "", + artistID: artistID, + artistName: artist, + duration: 1 + ))) + } + let library = MemoryLibrary() + await library.apply([ + .upsert(.artist(Artist(id: id("ar-1"), name: "Kai Angel"))), + .upsert(.artist(Artist(id: id("ar-2"), name: "Angel Olsen"))), + .upsert(.album(Album(id: id("al-1"), title: "Shh...", artistID: id("ar-1"), artistName: "Kai Angel"))), + track("t-1", "andy warhol", by: "Kai Angel", id("ar-1")), + track("t-2", "Fallen Angel", by: "Other", nil), + ]) + + let exact = await library.search("kai angel") + #expect(exact.top.map(\.id) == [id("ar-1"), id("t-1"), id("al-1")]) + #expect(exact.tracks.map(\.id) == [id("t-1")]) + + let broad = await library.search("angel") + #expect(broad.artists.map(\.id) == [id("ar-2"), id("ar-1")]) + #expect(broad.tracks.map(\.id) == [id("t-2"), id("t-1")]) +}