feat(mac,ios,playback,store): initial macos implementation
This commit is contained in:
1 parent
e5bb559d7f
commit
d2d7166cf0
72 files changed
+4364
-230
No files matched your search
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,11 @@
|
||||
private func playAlbum(_ album: Album, in app: AppModel) async {
|
||||
let router = app.router
|
||||
show(.album(album.id), in: router)
|
||||
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))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
enum Metrics {
|
||||
#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 {
|
||||
@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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
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)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
struct MiniPlayer: View {
|
||||
struct MiniPlayer: View {
|
||||
@Environment(PlayerModel.self) private var player
|
||||
@Environment(Router.self) private var router
|
||||
@Environment(\.tabViewBottomAccessoryPlacement) private var placement
|
||||
@@ -25,15 +26,10 @@ struct MiniPlayer: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Group {
|
||||
Button(
|
||||
player.isPlaying ? "Pause" as LocalizedStringKey : "Play",
|
||||
systemImage: player.isPlaying ? "pause.fill" : "play.fill"
|
||||
) {
|
||||
player.send(.togglePlayPause)
|
||||
}
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
Button { player.send(.togglePlayPause) } label: { PlayPauseGlyph(isPlaying: player.isPlaying) }
|
||||
.accessibilityLabel(player.isPlaying ? "Pause" : "Play")
|
||||
if placement != .inline {
|
||||
Button("Next", systemImage: "forward.fill") { player.send(.next) }
|
||||
SkipButton(backward: false) { player.send(.next) }
|
||||
}
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
@@ -44,5 +40,9 @@ struct MiniPlayer: View {
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
.padding(.trailing, 10)
|
||||
.frame(height: 44)
|
||||
.geometryGroup()
|
||||
.animation(.smooth(duration: 0.3), value: placement)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,94 @@
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.mask { QueueMask(scroll: scroll, hasHistory: hasHistory) }
|
||||
.overlay(alignment: .top) {
|
||||
if hasHistory {
|
||||
HistoryTitle(scroll: scroll)
|
||||
}
|
||||
}
|
||||
.onChange(of: router.showsQueue) {
|
||||
if !router.showsQueue {
|
||||
settle()
|
||||
}
|
||||
}
|
||||
.onChange(of: rest) {
|
||||
if abs(scroll.shift) < 1 {
|
||||
settle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -14,64 +97,57 @@ struct QueueView: View {
|
||||
player.send(.setRepeat(modes[(index + 1) % modes.count]))
|
||||
}
|
||||
}
|
||||
Text("Playing Next")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
.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)
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.scrollIndicators(.hidden)
|
||||
.overlay {
|
||||
if player.state.upcoming.isEmpty {
|
||||
Text("Nothing up next").foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
.mask {
|
||||
LinearGradient(
|
||||
stops: [.init(color: .black, location: 0.9), .init(color: .clear, location: 1)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
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 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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import AVKit
|
||||
import SwiftUI
|
||||
|
||||
struct RoutePicker: UIViewRepresentable {
|
||||
#if os(iOS)
|
||||
struct RoutePicker: UIViewRepresentable {
|
||||
func makeUIView(context _: Context) -> AVRoutePickerView {
|
||||
let view = AVRoutePickerView()
|
||||
view.tintColor = .white.withAlphaComponent(0.7)
|
||||
@@ -11,4 +12,17 @@ struct RoutePicker: UIViewRepresentable {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
@@ -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: [
|
||||
|
||||
@@ -7,16 +7,21 @@ struct RootView: View {
|
||||
|
||||
var body: some View {
|
||||
if app.hasSources {
|
||||
#if os(iOS)
|
||||
tabs
|
||||
#else
|
||||
MacRootView()
|
||||
#endif
|
||||
} else {
|
||||
SignInView()
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private var tabs: some View {
|
||||
@Bindable var router = router
|
||||
return TabView(selection: $router.tab) {
|
||||
Tab("Home", systemImage: "house.fill", value: AppTab.home) {
|
||||
Tab("Home", image: "Home", value: AppTab.home) {
|
||||
TabStack(tab: .home) { HomeView() }
|
||||
}
|
||||
Tab("Radio", systemImage: "dot.radiowaves.left.and.right", value: AppTab.radio) {
|
||||
@@ -39,6 +44,7 @@ struct RootView: View {
|
||||
.navigationTransition(.zoom(sourceID: "player", in: playerSpace))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
struct TabStack<Content: View>: View {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
.navigationTitle("Search")
|
||||
content
|
||||
.modifier(SearchTitle())
|
||||
.modifier(AccountToolbar())
|
||||
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Artists, Songs, Albums and More")
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
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
|
||||
}
|
||||
ForEach(results.albums) { album in
|
||||
link(.album(album.id), artwork: album.artwork, title: album.title, subtitle: Text("Album · \(album.artistName)"))
|
||||
|
||||
private var recents: [SearchHit] {
|
||||
(try? JSONDecoder().decode([SearchHit].self, from: recentData)) ?? []
|
||||
}
|
||||
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 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)
|
||||
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)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
.padding(.horizontal, Metrics.margin)
|
||||
.frame(minHeight: 60)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Divider().padding(.leading, Metrics.margin + 60)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -4,6 +4,8 @@
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>LSMultipleInstancesProhibited</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
|
||||
@@ -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,9 +130,16 @@
|
||||
adoptable = false
|
||||
}
|
||||
|
||||
private func makeSlot(_ item: QueueItem) async -> Slot? {
|
||||
private func resolveStream(_ item: QueueItem) async -> StreamRequest? {
|
||||
do {
|
||||
let request = try await resolve(item.track)
|
||||
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 }
|
||||
@@ -123,10 +147,25 @@
|
||||
Task { await self?.itemFailed(id) }
|
||||
}
|
||||
return Slot(item: item, playerItem: playerItem, statusObservation: observation)
|
||||
} catch {
|
||||
continuation.yield(.failed(item.id))
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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")])
|
||||
}
|
||||
Reference in new issue
Block a user