feat(mac,ios,playback,store): initial macos implementation

This commit is contained in:
hh committed 2026-09-23 04:56:48 +02:00
1 parent e5bb559d7f
commit d2d7166cf0
72 files changed
+4452 -318

No files matched your search

+26 -6
View File
@@ -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;
};
+10
View File
@@ -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
}
}
+15
View File
@@ -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 {
+6 -6
View File
@@ -1,8 +1,8 @@
import Domain
import UIKit
import Foundation
final class ArtworkLoader {
private let cache = NSCache<NSString, UIImage>()
private let cache = NSCache<NSString, PlatformImage>()
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)
}
}
+4
View File
@@ -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
)
}
+5 -1
View File
@@ -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))
+21
View File
@@ -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))
}
+20
View File
@@ -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
}
}
+62
View File
@@ -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)
}
}
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "Home.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
Binary file not shown.
+480
View File
@@ -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"
+11 -4
View File
@@ -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)
+56 -7
View File
@@ -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
}
}
+27 -1
View File
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
}
}
+12 -3
View File
@@ -3,17 +3,18 @@ import SwiftUI
struct Shelf<Content: View>: 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
}
}
+9 -1
View File
@@ -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
}
}
+33 -1
View File
@@ -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
}
}
+33 -8
View File
@@ -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)
+35 -5
View File
@@ -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
}
}
+2 -2
View File
@@ -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() }
+7 -7
View File
@@ -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) }
}
}
+125
View File
@@ -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
+81
View File
@@ -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
+62
View File
@@ -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
+41
View File
@@ -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
+117
View File
@@ -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
+221
View File
@@ -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<Void, Never>?
@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
+133
View File
@@ -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
+168
View File
@@ -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
+48
View File
@@ -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
+22
View File
@@ -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
+177
View File
@@ -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
+151
View File
@@ -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<Double>
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
+181
View File
@@ -0,0 +1,181 @@
#if os(macOS)
import AppKit
import SwiftUI
struct MacSliderInput: NSViewRepresentable {
let value: Double
let range: ClosedRange<Double>
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
+70
View File
@@ -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
+81
View File
@@ -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
+64
View File
@@ -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
@@ -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
@@ -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
@@ -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<Void, Never>?
@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
+7 -7
View File
@@ -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<Double>()) { $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<Double>]? {
guard let bitmap = image.cgImage, let srgb = CGColorSpace(name: CGColorSpace.sRGB),
private static func samples(of bitmap: CGImage) -> [SIMD3<Double>]? {
guard let srgb = CGColorSpace(name: CGColorSpace.sRGB),
let canvas = CGContext(
data: nil,
width: side,
+44 -13
View File
@@ -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)
}
}
+40 -40
View File
@@ -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
+54 -34
View File
@@ -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<Void, Never>?
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)
}
}
+45
View File
@@ -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)
}
}
+215 -59
View File
@@ -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
}
}
}
+24 -10
View File
@@ -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
+4 -2
View File
@@ -3,6 +3,8 @@ import SwiftUI
struct ScrubBar: View {
let value: Double
let range: ClosedRange<Double>
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)
}
+82
View File
@@ -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()
}
}
}
+8
View File
@@ -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))
}
+56
View File
@@ -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
+2 -2
View File
@@ -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: [
+29 -23
View File
@@ -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<Content: View>: View {
+154
View File
@@ -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
+161 -51
View File
@@ -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
}
}
+12 -4
View File
@@ -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
}
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
+2
View File
@@ -4,6 +4,8 @@
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>LSMultipleInstancesProhibited</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
+1
View File
@@ -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
@@ -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 {
@@ -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<Float64>.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<OutputDeviceInfo?> {
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<T>(_ selector: AudioObjectPropertySelector, from object: AudioObjectID, as _: T.Type) -> T? {
var address = address(selector)
var size = UInt32(MemoryLayout<T>.size)
let buffer = UnsafeMutablePointer<T>.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<AudioValueRange>.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
@@ -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 {}
}
@@ -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()
}
}
@@ -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
}
@@ -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] = []
@@ -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<Item> {
let item: Item
let score: Int
func map<Other>(_ transform: (Item) -> Other) -> Ranked<Other> {
Ranked<Other>(item: transform(item), score: score)
}
}
private func ranked<Item>(_ items: [Item], by score: (Item) -> Int?) -> [Ranked<Item>] {
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
}
@@ -6,8 +6,10 @@ import Testing
actor RecordingEngine: OutputEngine {
nonisolated let events: AsyncStream<OutputEvent>
nonisolated let continuation: AsyncStream<OutputEvent>.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<OutputEvent>.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)
}
@@ -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)
}
}
@@ -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")])
}