This commit is contained in:
Ultradesu
2026-06-08 16:50:16 +01:00
parent 2d4e623a9f
commit 2f8cff528c
30 changed files with 4296 additions and 15 deletions
+6 -4
View File
@@ -394,6 +394,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = furumi_macos/furumi_macos.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
@@ -402,8 +403,8 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = furumi_macos/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
@@ -426,6 +427,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = furumi_macos/furumi_macos.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
@@ -434,8 +436,8 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = furumi_macos/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
+40
View File
@@ -0,0 +1,40 @@
//
// ArtistDetailViewModel.swift
// furumi_macos
//
import Foundation
import Observation
@MainActor
@Observable
final class ArtistDetailViewModel {
private var service: CatalogService
private(set) var detail: ArtistDetail?
private(set) var isLoading = false
private(set) var error: String?
init(service: CatalogService) {
self.service = service
}
func reset(service: CatalogService) {
self.service = service
self.detail = nil
self.error = nil
self.isLoading = false
}
func load(artistId: Int64) async {
guard !isLoading else { return }
isLoading = true
error = nil
defer { isLoading = false }
do {
detail = try await service.artistDetail(id: artistId)
} catch {
self.error = (error as NSError).localizedDescription
self.detail = nil
}
}
}
+235
View File
@@ -0,0 +1,235 @@
//
// ArtistScreen.swift
// furumi_macos
//
import SwiftUI
struct ArtistScreen: View {
let artistId: Int64
let titleFallback: String
var onOpenRelease: (Int64, String) -> Void
var onBack: () -> Void
var onPlayTrack: (TrackCard) -> Void
var onAddToQueue: (TrackCard) -> Void
var onPlayNext: (TrackCard) -> Void
@Environment(AuthManager.self) private var authManager
@State private var vm: ArtistDetailViewModel?
var body: some View {
VStack(spacing: 0) {
// Back + title
HStack(spacing: 8) {
Button(action: onBack) {
Image(systemName: "chevron.left")
}
.buttonStyle(.plain)
Text(vm?.detail?.card.name ?? titleFallback)
.font(.headline)
.lineLimit(1)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
Divider()
if let vm {
if vm.isLoading && vm.detail == nil {
Spacer()
ProgressView()
Spacer()
} else if let error = vm.error {
Spacer()
Text(error).foregroundColor(.secondary).font(.caption).padding()
Spacer()
} else if let detail = vm.detail {
artistContent(detail)
}
} else {
Spacer()
ProgressView()
Spacer()
}
}
.task {
guard let service = authManager.catalogService else { return }
let newVM = ArtistDetailViewModel(service: service)
vm = newVM
await newVM.load(artistId: artistId)
}
}
@ViewBuilder
private func artistContent(_ detail: ArtistDetail) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Artist header
HStack(spacing: 12) {
ImageView(
urlString: detail.card.imageUrl,
width: 64, height: 64,
systemPlaceholder: "person.fill"
)
VStack(alignment: .leading, spacing: 4) {
Text(detail.card.name)
.font(.title3).fontWeight(.semibold)
Text("\(detail.card.releaseCount) releases • \(detail.card.trackCount) tracks")
.font(.caption).foregroundColor(.secondary)
}
Spacer()
}
.padding(.horizontal, 12)
// Releases grouped by type
let grouped = groupedReleases(detail.releases)
if !grouped.isEmpty {
ForEach(grouped, id: \.0) { label, releases in
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.headline)
.padding(.horizontal, 12)
ForEach(releases) { release in
ArtistReleaseRow(release: release)
.contentShape(Rectangle())
.onTapGesture { onOpenRelease(release.id, release.title) }
Divider().padding(.leading, 64)
}
}
}
}
// Top tracks
if !detail.topTracks.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Top Tracks")
.font(.headline)
.padding(.horizontal, 12)
ForEach(detail.topTracks) { track in
ArtistTrackRow(track: track,
onPlay: { onPlayTrack(track) },
onPlayNext: { onPlayNext(track) },
onAddToQueue: { onAddToQueue(track) })
Divider().padding(.leading, 64)
}
}
}
}
.padding(.vertical, 8)
}
}
private func groupedReleases(_ releases: [ReleaseCard]) -> [(String, [ReleaseCard])] {
let order = ["album", "ep", "single", "other"]
let labels = ["album": "Albums", "ep": "EPs", "single": "Singles", "other": "Other"]
let dict = Dictionary(grouping: releases) { $0.type.lowercased() }
var result: [(String, [ReleaseCard])] = []
for key in order {
if let items = dict[key], !items.isEmpty {
result.append((labels[key] ?? key.capitalized, items.sorted { $0.year > $1.year }))
}
}
for (key, items) in dict where !order.contains(key) && !items.isEmpty {
result.append((key.capitalized, items.sorted { $0.year > $1.year }))
}
return result
}
}
private struct ArtistReleaseRow: View {
let release: ReleaseCard
var body: some View {
HStack(spacing: 12) {
ImageView(urlString: release.coverUrl, width: 40, height: 40, systemPlaceholder: "opticaldisc")
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 2) {
Text(release.title).font(.subheadline).lineLimit(1)
Text(release.year > 0 ? String(release.year) : "").font(.caption).foregroundColor(.secondary)
}
Spacer()
Text("\(release.trackCount) tracks").font(.caption).foregroundColor(.secondary)
Image(systemName: "chevron.right").font(.caption).foregroundColor(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
}
private struct ArtistTrackRow: View {
let track: TrackCard
var onPlay: () -> Void
var onPlayNext: () -> Void
var onAddToQueue: () -> Void
@Environment(AuthManager.self) private var authManager
@State private var isLiked = false
@State private var showInfo = false
var body: some View {
HStack(spacing: 10) {
ImageView(urlString: track.coverUrl, width: 40, height: 40, systemPlaceholder: "music.note")
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 2) {
Text(track.title).font(.subheadline).lineLimit(1)
Text(track.artistNames).font(.caption).foregroundColor(.secondary).lineLimit(1)
}
Spacer()
Text(formatDuration(track.durationSeconds))
.font(.caption).foregroundColor(.secondary)
Button(action: { toggleLike() }) {
Image(systemName: isLiked ? "heart.fill" : "heart")
.font(.system(size: 13))
}
.buttonStyle(.plain)
.foregroundColor(isLiked ? .red : .secondary)
Menu {
Button { onPlayNext() } label: { Label("Play Next", systemImage: "text.insert") }
Button { onAddToQueue() } label: { Label("Add to Queue", systemImage: "text.badge.plus") }
Divider()
Button { shareTrack() } label: { Label("Share", systemImage: "square.and.arrow.up") }
Button { showInfo = true } label: { Label("Track Info", systemImage: "info.circle") }
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 13))
.frame(width: 20, height: 20)
}
.menuStyle(.borderlessButton)
.fixedSize()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture { onPlay() }
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
TrackInfoView(track: track)
}
}
private func toggleLike() {
isLiked.toggle()
let id = track.id
Task {
do {
let result = try await authManager.catalogService?.toggleLike(trackId: id)
if let result {
isLiked = result
authManager.updateLikedState(trackId: id, liked: result)
}
} catch {
isLiked.toggle()
}
}
}
private func shareTrack() {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString("\(track.title)\(track.artistNames)", forType: .string)
}
private func formatDuration(_ s: Int) -> String {
String(format: "%d:%02d", s / 60, s % 60)
}
}
+208
View File
@@ -0,0 +1,208 @@
//
// AuthManager.swift
// furumi_macos
//
import AppKit
import Observation
@MainActor
@Observable
final class AuthManager {
var session: AuthSession?
var isLoading = false
var error: String?
var likedTrackIds: Set<Int64> = []
private let service = AuthService()
private let storage = AuthSessionStorage()
init() {
session = storage.load()
}
// Catalog service bound to current session (closures keep token fresh)
var catalogService: CatalogService? {
guard let s = session else { return nil }
return CatalogService(
baseUrl: s.serverBaseUrl,
authHeaderProvider: { [self] in
await MainActor.run { self.session?.tokens.authorizationHeader ?? "" }
},
onUnauthorized: { [self] in
await self.refreshTokens()
}
)
}
// MARK: - Liked tracks
func loadLikedIds() async {
guard let service = catalogService else { return }
guard let ids = try? await service.likedIds() else { return }
likedTrackIds = ids
}
func updateLikedState(trackId: Int64, liked: Bool) {
if liked { likedTrackIds.insert(trackId) } else { likedTrackIds.remove(trackId) }
}
// MARK: - Token refresh
func refreshTokens() async -> Bool {
guard let current = session else { return false }
do {
let tokenResponse = try await service.refresh(
baseUrl: current.serverBaseUrl,
refreshToken: current.tokens.refreshToken
)
let now = Int64(Date().timeIntervalSince1970)
let expiresAt = tokenResponse.expiresInSeconds > 0
? now + Int64(tokenResponse.expiresInSeconds) : 0
let newSession = AuthSession(
serverBaseUrl: current.serverBaseUrl,
user: current.user,
tokens: AuthTokens(
accessToken: tokenResponse.accessToken,
refreshToken: tokenResponse.refreshToken,
tokenType: tokenResponse.tokenType,
expiresInSeconds: tokenResponse.expiresInSeconds,
expiresAtEpochSeconds: expiresAt
)
)
storage.save(newSession)
session = newSession
return true
} catch {
return false
}
}
// MARK: - Password login
func login(serverUrl: String, username: String, password: String) async {
guard let baseUrl = normalizeURL(serverUrl) else {
error = "Invalid server URL"
return
}
guard !username.trimmingCharacters(in: .whitespaces).isEmpty else {
error = "Username is required"
return
}
isLoading = true
error = nil
defer { isLoading = false }
do {
let response = try await service.login(baseUrl: baseUrl, username: username, password: password)
let authSession = response.toAuthSession(serverBaseUrl: baseUrl)
storage.save(authSession)
session = authSession
} catch {
self.error = error.localizedDescription
}
}
// MARK: - SSO
func startSSO(serverUrl: String) {
guard let baseUrl = normalizeURL(serverUrl) else {
error = "Invalid server URL"
return
}
storage.savePendingSsoBaseUrl(baseUrl)
guard let url = service.ssoStartURL(baseUrl: baseUrl) else {
error = "Failed to build SSO URL"
return
}
NSWorkspace.shared.open(url)
}
func completeSSOLogin(callbackUrl: String) async {
guard let url = URL(string: callbackUrl),
url.scheme == "furumi",
url.host == "auth",
url.path == "/callback" else {
return
}
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
if let ssoError = components?.queryItems?.first(where: { $0.name == "error" })?.value {
error = "SSO error: \(ssoError)"
return
}
guard let code = components?.queryItems?.first(where: { $0.name == "code" })?.value,
!code.isEmpty else {
error = "SSO callback does not contain an authorization code"
return
}
guard let baseUrl = storage.getPendingSsoBaseUrl() ?? session?.serverBaseUrl else {
error = "SSO server is missing. Start SSO login again."
return
}
isLoading = true
error = nil
defer { isLoading = false }
do {
let response = try await service.ssoExchange(baseUrl: baseUrl, code: code)
let authSession = response.toAuthSession(serverBaseUrl: baseUrl)
storage.save(authSession)
session = authSession
} catch {
self.error = error.localizedDescription
}
}
// MARK: - Logout
func logout() async {
guard let currentSession = session else { return }
isLoading = true
defer {
isLoading = false
storage.clear()
session = nil
}
try? await service.logout(
baseUrl: currentSession.serverBaseUrl,
authorizationHeader: currentSession.tokens.authorizationHeader,
refreshToken: currentSession.tokens.refreshToken
)
}
// MARK: - URL normalization (ported from Android ServerConfig)
func normalizeURL(_ raw: String) -> String? {
var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
while trimmed.hasSuffix("/") { trimmed = String(trimmed.dropLast()) }
guard !trimmed.isEmpty else { return nil }
if !trimmed.lowercased().hasPrefix("http://") && !trimmed.lowercased().hasPrefix("https://") {
trimmed = "https://\(trimmed)"
}
guard let url = URL(string: trimmed),
let scheme = url.scheme?.lowercased(),
(scheme == "http" || scheme == "https"),
let host = url.host, !host.isEmpty else {
return nil
}
var result = "\(scheme)://\(host.lowercased())"
if let port = url.port { result += ":\(port)" }
let path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if !path.isEmpty { result += "/\(path)" }
return result
}
}
+98
View File
@@ -0,0 +1,98 @@
//
// AuthModels.swift
// furumi_macos
//
import Foundation
// MARK: - Domain models
struct User: Codable {
let id: Int
let name: String
let role: String
}
struct AuthTokens: Codable {
let accessToken: String
let refreshToken: String
let tokenType: String
let expiresInSeconds: Int
let expiresAtEpochSeconds: Int64
var authorizationHeader: String {
"\(tokenType.isEmpty ? "Bearer" : tokenType) \(accessToken)"
}
func isExpired(nowEpochSeconds: Int64, skewSeconds: Int64 = 60) -> Bool {
expiresAtEpochSeconds > 0 && nowEpochSeconds >= expiresAtEpochSeconds - skewSeconds
}
}
struct AuthSession: Codable {
let serverBaseUrl: String
let user: User
let tokens: AuthTokens
}
// MARK: - Network response models
struct LoginResponse: Decodable {
let user: UserResponse
let tokens: TokenResponse
func toAuthSession(serverBaseUrl: String) -> AuthSession {
let now = Int64(Date().timeIntervalSince1970)
let expiresAt = tokens.expiresInSeconds > 0 ? now + Int64(tokens.expiresInSeconds) : 0
return AuthSession(
serverBaseUrl: serverBaseUrl,
user: User(id: user.id, name: user.name, role: user.role),
tokens: AuthTokens(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
tokenType: tokens.tokenType,
expiresInSeconds: tokens.expiresInSeconds,
expiresAtEpochSeconds: expiresAt
)
)
}
}
struct UserResponse: Decodable {
let id: Int
let name: String
let role: String
}
struct TokenResponse: Decodable {
let accessToken: String
let refreshToken: String
let tokenType: String
let expiresInSeconds: Int
}
struct ErrorResponse: Decodable {
let error: String
}
struct LogoutResponse: Decodable {
let revoked: Bool
}
// MARK: - Errors
enum AuthError: LocalizedError {
case invalidServerUrl(String)
case invalidResponse
case serverError(Int, String)
case ssoError(String)
var errorDescription: String? {
switch self {
case .invalidServerUrl(let msg): return msg
case .invalidResponse: return "Invalid server response"
case .serverError(let code, let msg): return msg.isEmpty ? "Server error \(code)" : msg
case .ssoError(let msg): return msg
}
}
}
+107
View File
@@ -0,0 +1,107 @@
//
// AuthService.swift
// furumi_macos
//
import Foundation
struct AuthService {
private let urlSession = URLSession.shared
private let decoder: JSONDecoder = {
let d = JSONDecoder()
d.keyDecodingStrategy = .convertFromSnakeCase
return d
}()
private var deviceName: String {
ProcessInfo.processInfo.hostName.components(separatedBy: ".").first ?? "macOS"
}
// MARK: - URL builders
func ssoStartURL(baseUrl: String) -> URL? {
guard var components = URLComponents(string: "\(baseUrl)/auth/mobile/oidc/start") else { return nil }
components.queryItems = [URLQueryItem(name: "redirect_uri", value: "furumi://auth/callback")]
return components.url
}
// MARK: - API
func login(baseUrl: String, username: String, password: String) async throws -> LoginResponse {
struct Body: Encodable {
let username: String
let password: String
let device_name: String
}
return try await post(
urlString: "\(baseUrl)/api/auth/password",
body: Body(username: username, password: password, device_name: deviceName)
)
}
func ssoExchange(baseUrl: String, code: String) async throws -> LoginResponse {
struct Body: Encodable {
let code: String
let device_name: String
}
return try await post(
urlString: "\(baseUrl)/api/auth/sso/exchange",
body: Body(code: code, device_name: deviceName)
)
}
func refresh(baseUrl: String, refreshToken: String) async throws -> TokenResponse {
struct Body: Encodable { let refresh_token: String }
return try await post(
urlString: "\(baseUrl)/api/auth/refresh",
body: Body(refresh_token: refreshToken)
)
}
func logout(baseUrl: String, authorizationHeader: String, refreshToken: String) async throws {
struct Body: Encodable { let refresh_token: String }
let _: LogoutResponse = try await post(
urlString: "\(baseUrl)/api/auth/logout",
body: Body(refresh_token: refreshToken),
authHeader: authorizationHeader
)
}
// MARK: - Private
private func post<B: Encodable, R: Decodable>(
urlString: String,
body: B,
authHeader: String? = nil
) async throws -> R {
guard let url = URL(string: urlString) else {
throw AuthError.invalidServerUrl("Invalid URL: \(urlString)")
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let authHeader { request.setValue(authHeader, forHTTPHeaderField: "Authorization") }
request.httpBody = try JSONEncoder().encode(body)
let (data, response) = try await urlSession.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw AuthError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
let msg = (try? decoder.decode(ErrorResponse.self, from: data))?.error ?? ""
throw AuthError.serverError(http.statusCode, msg)
}
do {
return try decoder.decode(R.self, from: data)
} catch {
throw AuthError.invalidResponse
}
}
}
+81
View File
@@ -0,0 +1,81 @@
//
// AuthSessionStorage.swift
// furumi_macos
//
import Foundation
import Security
final class AuthSessionStorage {
private let service = "cy.hexor.furumi"
private let sessionAccount = "auth_session"
private let pendingSsoAccount = "pending_sso_url"
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
func save(_ session: AuthSession) {
guard let data = try? encoder.encode(session) else { return }
setItem(account: sessionAccount, data: data)
}
func load() -> AuthSession? {
guard let data = getItem(account: sessionAccount) else { return nil }
return try? decoder.decode(AuthSession.self, from: data)
}
func clear() {
deleteItem(account: sessionAccount)
deleteItem(account: pendingSsoAccount)
}
func savePendingSsoBaseUrl(_ url: String) {
guard let data = url.data(using: .utf8) else { return }
setItem(account: pendingSsoAccount, data: data)
}
func getPendingSsoBaseUrl() -> String? {
guard let data = getItem(account: pendingSsoAccount) else { return nil }
return String(data: data, encoding: .utf8)
}
// MARK: - Keychain
private func setItem(account: String, data: Data) {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account
]
let update: [CFString: Any] = [kSecValueData: data]
if SecItemUpdate(query as CFDictionary, update as CFDictionary) == errSecItemNotFound {
var add = query
add[kSecValueData] = data
SecItemAdd(add as CFDictionary, nil)
}
}
private func getItem(account: String) -> Data? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecReturnData: kCFBooleanTrue as Any,
kSecMatchLimit: kSecMatchLimitOne
]
var result: AnyObject?
SecItemCopyMatching(query as CFDictionary, &result)
return result as? Data
}
private func deleteItem(account: String) {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account
]
SecItemDelete(query as CFDictionary)
}
}
+387
View File
@@ -0,0 +1,387 @@
//
// CatalogModels.swift
// furumi_macos
//
import Foundation
// MARK: - URL resolver
enum URLResolver {
static func absolute(from pathOrUrl: String, baseUrl: String) -> String {
let lower = pathOrUrl.lowercased()
if lower.hasPrefix("http://") || lower.hasPrefix("https://") {
return pathOrUrl
}
let trimmedBase = baseUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let trimmedPath = pathOrUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "\(trimmedBase)/\(trimmedPath)"
}
}
// MARK: - DTOs
struct ArtistCardDTO: Decodable {
let id: Int64
let name: String
let imageUrl: String?
let releaseCount: Int? // nullable on server (default 0)
let trackCount: Int? // nullable on server (default 0)
}
struct ArtistPageDTO: Decodable {
let items: [ArtistCardDTO]
let total: Int
let page: Int
let perPage: Int
let hasMore: Bool
}
struct ReleaseCardDTO: Decodable {
let id: Int64
let title: String
let releaseType: String? // nullable on server
let year: Int? // nullable on server
let coverUrl: String?
let trackCount: Int? // nullable on server
}
struct TrackArtistDTO: Decodable {
let id: Int64
let name: String
}
struct TrackItemDTO: Decodable {
let id: Int64
let title: String
let trackNumber: Int?
let discNumber: Int?
let durationSeconds: Double
let artists: [TrackArtistDTO]?
let featuredArtists: [TrackArtistDTO]?
let releaseId: Int64?
let releaseTitle: String?
let releaseYear: Int?
let coverUrl: String?
let streamUrl: String
let uploaderName: String?
let audioFormat: String?
let audioBitrate: Int?
let audioSampleRate: Int?
let audioBitDepth: Int?
let fileSizeBytes: Int64?
let lastfmListeners: Int64?
let lastfmPlaycount: Int64?
let lastfmRating: Double?
let lastfmUpdatedAt: String?
}
struct ArtistDetailDTO: Decodable {
// artist может быть null; fallback поля на корне:
let artist: ArtistCardDTO?
let id: Int64?
let name: String?
let imageUrl: String?
let releaseCount: Int?
let trackCount: Int?
let releases: [ReleaseCardDTO]
let topTracks: [TrackItemDTO]?
let featuredTracks: [TrackItemDTO]?
}
struct ReleaseDetailDTO: Decodable {
let release: ReleaseCardDTO?
let id: Int64?
let title: String?
let releaseType: String?
let year: Int?
let coverUrl: String?
let trackCount: Int?
let artists: [ArtistCardDTO]?
let tracks: [TrackItemDTO]
}
struct SearchResponseDTO: Decodable {
let artists: [ArtistCardDTO]
let releases: [ReleaseCardDTO]
let tracks: [TrackItemDTO]
}
struct PlaylistCardDTO: Decodable {
let id: Int64
let title: String
let trackCount: Int64
let isOwn: Bool
let ownerName: String?
let isPublic: Bool
let isSaved: Bool
let kind: String
}
struct PlaylistDetailDTO: Decodable {
let id: Int64
let title: String
let description: String?
let isOwn: Bool
let ownerName: String?
let isPublic: Bool
let isSaved: Bool
let kind: String
let tracks: [TrackItemDTO]
}
struct LikedIdsDTO: Decodable {
let trackIds: [Int64]
}
// MARK: - Domain models
struct ArtistCard: Identifiable, Hashable {
let id: Int64
let name: String
let imageUrl: String?
let releaseCount: Int
let trackCount: Int
}
struct ArtistPage {
let items: [ArtistCard]
let total: Int
let page: Int
let perPage: Int
let hasMore: Bool
}
struct ReleaseCard: Identifiable, Hashable {
let id: Int64
let title: String
let type: String
let year: Int
let coverUrl: String?
let trackCount: Int
}
struct TrackCard: Identifiable, Hashable {
let id: Int64
let title: String
let trackNumber: Int?
let discNumber: Int?
let durationSeconds: Int
let artistNames: String
let coverUrl: String?
let streamUrl: String
let releaseId: Int64
let releaseTitle: String
let releaseYear: Int?
let uploaderName: String
let audioFormat: String?
let audioBitrate: Int?
let audioSampleRate: Int?
let audioBitDepth: Int?
let fileSizeBytes: Int64?
let lastfmListeners: Int64?
let lastfmPlaycount: Int64?
let lastfmRating: Double?
let lastfmUpdatedAt: String?
}
struct ArtistDetail {
let card: ArtistCard
let releases: [ReleaseCard]
let topTracks: [TrackCard]
let featuredTracks: [TrackCard]
}
struct ReleaseDetail {
let card: ReleaseCard
let artists: [ArtistCard]
let tracks: [TrackCard]
}
struct SearchResult {
let artists: [ArtistCard]
let releases: [ReleaseCard]
let tracks: [TrackCard]
}
struct PlaylistCard: Identifiable, Hashable {
let id: Int64
let title: String
let trackCount: Int
let isOwn: Bool
let ownerName: String?
let kind: String
}
struct PlaylistDetail {
let card: PlaylistCard
let tracks: [TrackCard]
}
// MARK: - Mapping
extension ArtistCardDTO {
func toDomain(baseUrl: String) -> ArtistCard {
ArtistCard(
id: id,
name: name,
imageUrl: imageUrl.map { URLResolver.absolute(from: $0, baseUrl: baseUrl) },
releaseCount: releaseCount ?? 0,
trackCount: trackCount ?? 0
)
}
}
extension ArtistPageDTO {
func toDomain(baseUrl: String) -> ArtistPage {
ArtistPage(
items: items.map { $0.toDomain(baseUrl: baseUrl) },
total: total,
page: page,
perPage: perPage,
hasMore: hasMore
)
}
}
extension ReleaseCardDTO {
func toDomain(baseUrl: String) -> ReleaseCard {
ReleaseCard(
id: id,
title: title,
type: releaseType ?? "",
year: year ?? 0,
coverUrl: coverUrl.map { URLResolver.absolute(from: $0, baseUrl: baseUrl) },
trackCount: trackCount ?? 0
)
}
}
extension TrackItemDTO {
func toDomain(baseUrl: String) -> TrackCard {
let names: [String]
if let a = artists, !a.isEmpty {
names = a.map { $0.name }
} else if let f = featuredArtists, !f.isEmpty {
names = f.map { $0.name }
} else {
names = []
}
return TrackCard(
id: id,
title: title,
trackNumber: trackNumber,
discNumber: discNumber,
durationSeconds: Int(durationSeconds.rounded()),
artistNames: names.joined(separator: ", "),
coverUrl: coverUrl.map { URLResolver.absolute(from: $0, baseUrl: baseUrl) },
streamUrl: URLResolver.absolute(from: streamUrl, baseUrl: baseUrl),
releaseId: releaseId ?? 0,
releaseTitle: releaseTitle ?? "",
releaseYear: releaseYear,
uploaderName: uploaderName ?? "",
audioFormat: audioFormat,
audioBitrate: audioBitrate,
audioSampleRate: audioSampleRate,
audioBitDepth: audioBitDepth,
fileSizeBytes: fileSizeBytes,
lastfmListeners: lastfmListeners,
lastfmPlaycount: lastfmPlaycount,
lastfmRating: lastfmRating,
lastfmUpdatedAt: lastfmUpdatedAt
)
}
}
extension ArtistDetailDTO {
func toDomain(baseUrl: String) -> ArtistDetail {
let rels = releases.map { $0.toDomain(baseUrl: baseUrl) }
let top = (topTracks ?? []).map { $0.toDomain(baseUrl: baseUrl) }
let feat = (featuredTracks ?? []).map { $0.toDomain(baseUrl: baseUrl) }
var base: ArtistCard
if let artist = artist {
base = artist.toDomain(baseUrl: baseUrl)
} else {
let img = imageUrl.map { URLResolver.absolute(from: $0, baseUrl: baseUrl) }
base = ArtistCard(
id: id ?? 0,
name: name ?? "",
imageUrl: img,
releaseCount: releaseCount ?? 0,
trackCount: trackCount ?? 0
)
}
// If server omitted counts, fall back to actual array sizes
if base.releaseCount == 0 && !rels.isEmpty {
base = ArtistCard(id: base.id, name: base.name, imageUrl: base.imageUrl,
releaseCount: rels.count, trackCount: base.trackCount)
}
if base.trackCount == 0 && !(top + feat).isEmpty {
base = ArtistCard(id: base.id, name: base.name, imageUrl: base.imageUrl,
releaseCount: base.releaseCount, trackCount: top.count + feat.count)
}
return ArtistDetail(card: base, releases: rels, topTracks: top, featuredTracks: feat)
}
}
extension ReleaseDetailDTO {
func toDomain(baseUrl: String) -> ReleaseDetail {
let card: ReleaseCard
if let r = release {
card = r.toDomain(baseUrl: baseUrl)
} else {
let cover = coverUrl.map { URLResolver.absolute(from: $0, baseUrl: baseUrl) }
card = ReleaseCard(
id: id ?? 0,
title: title ?? "",
type: releaseType ?? "",
year: year ?? 0,
coverUrl: cover,
trackCount: trackCount ?? 0
)
}
let arts = (artists ?? []).map { $0.toDomain(baseUrl: baseUrl) }
let tr = tracks.map { $0.toDomain(baseUrl: baseUrl) }
return ReleaseDetail(card: card, artists: arts, tracks: tr)
}
}
extension SearchResponseDTO {
func toDomain(baseUrl: String) -> SearchResult {
SearchResult(
artists: artists.map { $0.toDomain(baseUrl: baseUrl) },
releases: releases.map { $0.toDomain(baseUrl: baseUrl) },
tracks: tracks.map { $0.toDomain(baseUrl: baseUrl) }
)
}
}
extension PlaylistCardDTO {
func toDomain() -> PlaylistCard {
PlaylistCard(
id: id,
title: title,
trackCount: Int(trackCount),
isOwn: isOwn,
ownerName: ownerName,
kind: kind
)
}
}
extension PlaylistDetailDTO {
func toDomain(baseUrl: String) -> PlaylistDetail {
let card = PlaylistCard(
id: id,
title: title,
trackCount: tracks.count,
isOwn: isOwn,
ownerName: ownerName,
kind: kind
)
return PlaylistDetail(card: card, tracks: tracks.map { $0.toDomain(baseUrl: baseUrl) })
}
}
+240
View File
@@ -0,0 +1,240 @@
//
// CatalogService.swift
// furumi_macos
//
import Foundation
enum CatalogError: LocalizedError {
case httpError(statusCode: Int, message: String)
case decodingError(String)
var errorDescription: String? {
switch self {
case .httpError(let code, let message):
return message.isEmpty ? "Server error \(code)" : "Server error \(code): \(message)"
case .decodingError(let detail):
return "Response parsing failed: \(detail)"
}
}
}
struct CatalogService {
let baseUrl: String
let authHeaderProvider: () async -> String
let onUnauthorized: () async -> Bool
private let decoder: JSONDecoder = {
let d = JSONDecoder()
d.keyDecodingStrategy = .convertFromSnakeCase
return d
}()
private func makeRequest(path: String, query: [URLQueryItem] = []) async throws -> URLRequest {
guard var components = URLComponents(string: baseUrl) else {
throw URLError(.badURL)
}
components.path = "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if !query.isEmpty {
components.queryItems = query
}
guard let url = components.url else { throw URLError(.badURL) }
var req = URLRequest(url: url)
req.httpMethod = "GET"
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.setValue(await authHeaderProvider(), forHTTPHeaderField: "Authorization")
return req
}
private func checkResponse(_ response: URLResponse, data: Data) throws {
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard (200..<300).contains(http.statusCode) else {
let message = (try? decoder.decode(ErrorResponse.self, from: data))?.error
?? (String(data: data, encoding: .utf8).map { $0.prefix(200) }.map(String.init) ?? "")
throw CatalogError.httpError(statusCode: http.statusCode, message: message)
}
}
private func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
do {
return try decoder.decode(type, from: data)
} catch let e as DecodingError {
throw CatalogError.decodingError(e.localizedDescription)
}
}
// Fetches data with a single 401-triggered refresh+retry
private func post(path: String, body: Data) async throws -> Data {
guard var components = URLComponents(string: baseUrl) else { throw URLError(.badURL) }
components.path = "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let url = components.url else { throw URLError(.badURL) }
func makeReq() async -> URLRequest {
var r = URLRequest(url: url)
r.httpMethod = "POST"
r.setValue("application/json", forHTTPHeaderField: "Content-Type")
r.setValue("application/json", forHTTPHeaderField: "Accept")
r.httpBody = body
return r
}
var req = await makeReq()
req.setValue(await authHeaderProvider(), forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: req)
if let http = response as? HTTPURLResponse, http.statusCode == 401 {
guard await onUnauthorized() else {
throw CatalogError.httpError(statusCode: 401, message: "Unauthorized")
}
var retry = await makeReq()
retry.setValue(await authHeaderProvider(), forHTTPHeaderField: "Authorization")
let (data2, response2) = try await URLSession.shared.data(for: retry)
try checkResponse(response2, data: data2)
return data2
}
try checkResponse(response, data: data)
return data
}
private func fetch(path: String, query: [URLQueryItem] = []) async throws -> Data {
let req = try await makeRequest(path: path, query: query)
let (data, response) = try await URLSession.shared.data(for: req)
if let http = response as? HTTPURLResponse, http.statusCode == 401 {
guard await onUnauthorized() else {
throw CatalogError.httpError(statusCode: 401, message: "Unauthorized")
}
let retryReq = try await makeRequest(path: path, query: query)
let (data2, response2) = try await URLSession.shared.data(for: retryReq)
try checkResponse(response2, data: data2)
return data2
}
try checkResponse(response, data: data)
return data
}
func artists(page: Int, limit: Int, mine: Bool) async throws -> ArtistPage {
let data = try await fetch(
path: "api/player/artists",
query: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
URLQueryItem(name: "mine", value: mine ? "true" : "false")
]
)
return try decode(ArtistPageDTO.self, from: data).toDomain(baseUrl: baseUrl)
}
func artistDetail(id: Int64) async throws -> ArtistDetail {
let data = try await fetch(path: "api/player/artists/\(id)")
return try decode(ArtistDetailDTO.self, from: data).toDomain(baseUrl: baseUrl)
}
func releaseDetail(id: Int64) async throws -> ReleaseDetail {
let data = try await fetch(path: "api/player/releases/\(id)")
return try decode(ReleaseDetailDTO.self, from: data).toDomain(baseUrl: baseUrl)
}
func search(query: String, limit: Int) async throws -> SearchResult {
let data = try await fetch(
path: "api/player/search",
query: [
URLQueryItem(name: "q", value: query),
URLQueryItem(name: "limit", value: String(limit))
]
)
return try decode(SearchResponseDTO.self, from: data).toDomain(baseUrl: baseUrl)
}
// MARK: - Device Hub
private static let deviceEncoder: JSONEncoder = {
let e = JSONEncoder()
e.keyEncodingStrategy = .convertToSnakeCase
return e
}()
func devicePoll(deviceId: String, userAgent: String, playbackState: DevicePlaybackState?) async throws -> Data {
struct Body: Encodable {
let deviceId: String
let userAgent: String?
let currentJamId: String?
let playbackState: DevicePlaybackState?
}
let body = try Self.deviceEncoder.encode(Body(deviceId: deviceId, userAgent: userAgent, currentJamId: nil, playbackState: playbackState))
return try await post(path: "api/player/devices/poll", body: body)
}
func deviceSelect(currentDeviceId: String, targetDeviceId: String) async throws -> Data {
struct Body: Encodable {
let deviceId: String
let currentDeviceId: String?
}
let body = try Self.deviceEncoder.encode(Body(deviceId: targetDeviceId, currentDeviceId: currentDeviceId))
return try await post(path: "api/player/devices/active", body: body)
}
func deviceCommand(command: String, targetDeviceId: String?, payloadData: Data) async throws {
// Build JSON manually to embed arbitrary payload
var obj: [String: Any] = ["command": command]
if let tid = targetDeviceId { obj["target_device_id"] = tid }
if let payloadObj = try? JSONSerialization.jsonObject(with: payloadData) {
obj["payload"] = payloadObj
} else {
obj["payload"] = [String: Any]()
}
let body = try JSONSerialization.data(withJSONObject: obj)
_ = try await post(path: "api/player/devices/command", body: body)
}
func playlists() async throws -> [PlaylistCard] {
let data = try await fetch(path: "api/player/playlists")
return try decode([PlaylistCardDTO].self, from: data).map { $0.toDomain() }
}
func playlistDetail(id: Int64) async throws -> PlaylistDetail {
let data = try await fetch(path: "api/player/playlists/\(id)")
return try decode(PlaylistDetailDTO.self, from: data).toDomain(baseUrl: baseUrl)
}
func likedIds() async throws -> Set<Int64> {
let data = try await fetch(path: "api/player/likes")
let dto = try decode(LikedIdsDTO.self, from: data)
return Set(dto.trackIds)
}
// Returns new liked state after toggle
func toggleLike(trackId: Int64) async throws -> Bool {
guard var components = URLComponents(string: baseUrl) else { throw URLError(.badURL) }
components.path = "/api/player/likes/toggle/\(trackId)"
guard let url = components.url else { throw URLError(.badURL) }
func makeReq() async -> URLRequest {
var r = URLRequest(url: url)
r.httpMethod = "POST"
r.setValue("application/json", forHTTPHeaderField: "Accept")
r.setValue(await authHeaderProvider(), forHTTPHeaderField: "Authorization")
return r
}
let req = await makeReq()
let (data, response) = try await URLSession.shared.data(for: req)
if let http = response as? HTTPURLResponse, http.statusCode == 401 {
guard await onUnauthorized() else {
throw CatalogError.httpError(statusCode: 401, message: "Unauthorized")
}
let retryReq = await makeReq()
let (data2, response2) = try await URLSession.shared.data(for: retryReq)
try checkResponse(response2, data: data2)
return try decode(LikeStatusDTO.self, from: data2).liked
}
try checkResponse(response, data: data)
return try decode(LikeStatusDTO.self, from: data).liked
}
}
private struct LikeStatusDTO: Decodable { let liked: Bool }
+649 -6
View File
@@ -7,18 +7,661 @@
import SwiftUI
// MARK: - Navigation
enum PlayerTab: String, CaseIterable, Identifiable {
case global = "Global"
case uploads = "My Uploads"
case playlists = "Playlists"
var id: String { rawValue }
}
enum Route: Hashable {
case artist(Int64, String) // id, name
case release(Int64, String) // id, title
case playlist(Int64, String) // id, title
}
struct ContentView: View {
@Environment(AuthManager.self) private var authManager
@State private var selectedTab: PlayerTab = .global
@State private var navStack: [Route] = []
@State private var player = PlayerManager()
@State private var deviceManager = DeviceManager()
@State private var isCurrentTrackLiked = false
@State private var showingTrackInfo = false
@State private var showingQueue = false
@State private var showingDevices = false
// ViewModels bound to session (optional)
@State private var globalVM: GlobalArtistsViewModel?
@State private var myUploadsVM: GlobalArtistsViewModel?
@State private var searchVM: SearchViewModel?
@State private var uploadsSearchVM: SearchViewModel?
@State private var playlistsVM: PlaylistsViewModel?
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
Group {
if authManager.session == nil {
unauthorizedHelpView
} else {
contentWithTabs
}
}
.padding()
.onChange(of: authManager.session != nil) { _, _ in
rebuildServices()
}
.onAppear {
rebuildServices()
}
.frame(width: 360, height: 560)
.animation(.default, value: selectedTab)
.animation(.default, value: navStack)
.onChange(of: player.currentTrack?.id) { _, newId in
isCurrentTrackLiked = newId.map { authManager.likedTrackIds.contains($0) } ?? false
}
.onChange(of: deviceManager.pendingTransferState != nil) { _, hasPending in
if hasPending, let state = deviceManager.pendingTransferState {
applyTransferState(state)
deviceManager.clearPendingTransfer()
}
}
}
private func rebuildServices() {
guard let service = authManager.catalogService else {
globalVM = nil
myUploadsVM = nil
searchVM = nil
uploadsSearchVM = nil
playlistsVM = nil
deviceManager.stop()
return
}
if let vm = globalVM { vm.reset(service: service) } else {
globalVM = GlobalArtistsViewModel(service: service, mine: false)
}
if let vm = myUploadsVM { vm.reset(service: service) } else {
myUploadsVM = GlobalArtistsViewModel(service: service, mine: true)
}
if let vm = searchVM { vm.reset(service: service) } else {
searchVM = SearchViewModel(service: service)
}
if let vm = uploadsSearchVM { vm.reset(service: service) } else {
uploadsSearchVM = SearchViewModel(service: service)
}
if let vm = playlistsVM { vm.reset(service: service) } else {
playlistsVM = PlaylistsViewModel(service: service)
}
Task { await authManager.loadLikedIds() }
deviceManager.start(service: service, player: player)
let gvm = globalVM
Task { await gvm?.loadFirstPage() }
let pvm = playlistsVM
Task { await pvm?.load() }
}
// MARK: - Unauthorized help
private var unauthorizedHelpView: some View {
VStack(spacing: 16) {
header
Divider()
VStack(spacing: 12) {
Image(systemName: "lock.slash")
.font(.system(size: 40))
.foregroundColor(.secondary)
.padding(.bottom, 4)
Text("Youre not signed in")
.font(.headline)
Text("Please open Settings and sign in to your server to start playing music.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
Button {
openSettings()
} label: {
HStack(spacing: 6) {
Image(systemName: "gearshape")
Text("Open Settings")
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.keyboardShortcut(",", modifiers: [.command])
}
Spacer()
}
.padding(.bottom, 16)
}
// MARK: - Authorized content with tabs
private var contentWithTabs: some View {
VStack(spacing: 0) {
header
// Tabs
Picker("", selection: $selectedTab) {
ForEach(PlayerTab.allCases) { tab in
Text(tab.rawValue).tag(tab)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 12)
.padding(.bottom, 8)
.onChange(of: selectedTab) { _, _ in
navStack.removeAll()
if selectedTab == .global {
Task { await globalVM?.loadFirstPage() }
} else if selectedTab == .uploads {
Task { await myUploadsVM?.loadFirstPage() }
} else if selectedTab == .playlists {
Task { await playlistsVM?.load() }
}
}
Divider()
Group {
switch selectedTab {
case .global:
globalBody
case .uploads:
uploadsBody
case .playlists:
playlistsBody
}
}
Divider()
nowPlayingBar
.padding(12)
}
}
// MARK: - Global tab
@ViewBuilder
private var globalBody: some View {
if let route = navStack.last {
switch route {
case .artist(let id, let name):
ArtistScreen(artistId: id, titleFallback: name, onOpenRelease: { rid, rtitle in
navStack.append(.release(rid, rtitle))
}, onBack: { _ = navStack.popLast() }, onPlayTrack: playTrack, onAddToQueue: addToQueue, onPlayNext: addToQueueNext)
case .release(let id, let title):
ReleaseScreen(releaseId: id, titleFallback: title, onBack: { _ = navStack.popLast() }, onPlayTrack: playTrack, onAddToQueue: addToQueue, onPlayNext: addToQueueNext)
case .playlist:
EmptyView()
}
} else {
VStack(spacing: 0) {
if let searchVM {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
TextField("Search artists, releases, tracks", text: Binding(
get: { searchVM.query },
set: { searchVM.query = $0 }
))
.textFieldStyle(.roundedBorder)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
if let searchVM, let results = searchVM.results, !searchVM.query.isEmpty {
SearchResultsView(results: results, onSelectArtist: { artist in
navStack.append(.artist(artist.id, artist.name))
}, onSelectRelease: { rel in
navStack.append(.release(rel.id, rel.title))
}, onSelectTrack: { track in
playTrack(track)
}, onAddToQueue: { track in
addToQueue(track)
}, onPlayNext: { track in
addToQueueNext(track)
})
} else if let vm = globalVM {
artistsBody(vm: vm)
} else {
ProgressView().padding()
}
}
}
}
// MARK: - Uploads tab
@ViewBuilder
private var uploadsBody: some View {
if let route = navStack.last {
switch route {
case .artist(let id, let name):
ArtistScreen(artistId: id, titleFallback: name, onOpenRelease: { rid, rtitle in
navStack.append(.release(rid, rtitle))
}, onBack: { _ = navStack.popLast() }, onPlayTrack: playTrack, onAddToQueue: addToQueue, onPlayNext: addToQueueNext)
case .release(let id, let title):
ReleaseScreen(releaseId: id, titleFallback: title, onBack: { _ = navStack.popLast() }, onPlayTrack: playTrack, onAddToQueue: addToQueue, onPlayNext: addToQueueNext)
case .playlist:
EmptyView()
}
} else {
VStack(spacing: 0) {
if let searchVM = uploadsSearchVM {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
TextField("Search artists, releases, tracks", text: Binding(
get: { searchVM.query },
set: { searchVM.query = $0 }
))
.textFieldStyle(.roundedBorder)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
if let searchVM = uploadsSearchVM, let results = searchVM.results, !searchVM.query.isEmpty {
SearchResultsView(results: results, onSelectArtist: { artist in
navStack.append(.artist(artist.id, artist.name))
}, onSelectRelease: { rel in
navStack.append(.release(rel.id, rel.title))
}, onSelectTrack: { track in
playTrack(track)
}, onAddToQueue: { track in
addToQueue(track)
}, onPlayNext: { track in
addToQueueNext(track)
})
} else if let vm = myUploadsVM {
artistsBody(vm: vm)
} else {
ProgressView().padding()
}
}
}
}
// MARK: - Playlists tab
@ViewBuilder
private var playlistsBody: some View {
if let route = navStack.last, case .playlist(let id, let title) = route {
PlaylistScreen(
playlistId: id,
titleFallback: title,
onBack: { _ = navStack.popLast() },
onPlayTrack: playTrack,
onAddToQueue: addToQueue,
onPlayNext: addToQueueNext
)
} else if let vm = playlistsVM {
if let error = vm.error {
VStack(spacing: 12) {
Spacer()
Text(error).font(.caption).foregroundColor(.secondary)
.multilineTextAlignment(.center).padding(.horizontal, 24)
Button("Retry") { Task { await vm.load() } }.buttonStyle(.bordered)
Spacer()
}
} else if vm.isLoading && vm.playlists.isEmpty {
VStack { Spacer(); ProgressView(); Spacer() }
} else {
PlaylistsGridView(playlists: vm.playlists) { playlist in
navStack.append(.playlist(playlist.id, playlist.title))
}
.task { await vm.load() }
}
} else {
ProgressView().padding()
}
}
@ViewBuilder
private func artistsBody(vm: GlobalArtistsViewModel) -> some View {
if let error = vm.error {
VStack(spacing: 12) {
Spacer()
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 28))
.foregroundColor(.secondary)
Text(error)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
Button("Retry") { Task { await vm.loadFirstPage() } }
.buttonStyle(.bordered)
Spacer()
}
} else if vm.isLoading && vm.artists.isEmpty {
VStack { Spacer(); ProgressView(); Spacer() }
} else {
GlobalArtistsGridView(
artists: vm.artists,
onSelect: { artist in navStack.append(.artist(artist.id, artist.name)) },
onAppearLast: { lastID in
Task { await vm.loadNextPageIfNeeded(lastVisibleID: lastID) }
}
)
.task { await vm.loadFirstPage() }
}
}
// MARK: - Header / Player
private var header: some View {
HStack {
Text("Furumi")
.font(.headline)
.foregroundColor(.primary)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
// MARK: - Display helpers (local or remote state)
private var displayCoverUrl: String? {
deviceManager.isThisDeviceActive
? player.currentTrack?.coverUrl
: deviceManager.remoteState?.track?.coverUrl
}
private var displayTitle: String {
deviceManager.isThisDeviceActive
? (player.currentTrack?.title ?? "")
: (deviceManager.remoteState?.track?.displayTitle ?? "")
}
private var displayArtist: String {
deviceManager.isThisDeviceActive
? (player.currentTrack?.artistNames ?? "")
: (deviceManager.remoteState?.track?.displayArtists ?? "")
}
private var displayCurrentTime: Double {
deviceManager.isThisDeviceActive
? player.currentTime
: (deviceManager.remoteState?.estimatedPositionSeconds ?? 0)
}
private var displayDuration: Double {
deviceManager.isThisDeviceActive
? player.duration
: (deviceManager.remoteState?.durationSeconds ?? 0)
}
private var displayProgress: Double {
let dur = displayDuration
guard dur > 0 else { return 0 }
return min(displayCurrentTime / dur, 1)
}
private var displayIsPlaying: Bool {
deviceManager.isThisDeviceActive
? player.isPlaying
: (deviceManager.remoteState.map { !$0.paused } ?? false)
}
private var displayHasTrack: Bool {
deviceManager.isThisDeviceActive ? player.currentTrack != nil : deviceManager.remoteState?.track != nil
}
private func formatBarTime(_ s: Double) -> String {
guard s.isFinite, s >= 0 else { return "--:--" }
let t = Int(s)
return String(format: "%d:%02d", t / 60, t % 60)
}
private var nowPlayingBar: some View {
VStack(spacing: 6) {
HStack(spacing: 12) {
ImageView(
urlString: displayCoverUrl,
width: 36, height: 36,
systemPlaceholder: "music.note"
)
VStack(alignment: .leading, spacing: 2) {
Text(displayTitle)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
Text(displayArtist)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
Spacer()
// Device selector button
Button(action: { showingDevices.toggle() }) {
Image(systemName: deviceManager.isThisDeviceActive ? "laptopcomputer" : "airplayaudio")
.font(.system(size: 12))
}
.buttonStyle(.plain)
.foregroundColor(deviceManager.isThisDeviceActive ? .secondary : .accentColor)
.popover(isPresented: $showingDevices, arrowEdge: .bottom) {
DevicesView(deviceManager: deviceManager)
}
// elapsed / total
Text("\(formatBarTime(displayCurrentTime)) / \(formatBarTime(displayDuration))")
.font(.caption)
.foregroundColor(.secondary)
.monospacedDigit()
}
Slider(value: Binding(
get: { displayProgress },
set: { fraction in
if deviceManager.isThisDeviceActive {
player.seek(to: fraction)
} else {
let pos = fraction * displayDuration
Task { await deviceManager.sendCommand("seek", payload: ["position_seconds": pos]) }
}
}
))
.tint(.accentColor)
.disabled(!displayHasTrack)
HStack {
// Transport
HStack(spacing: 16) {
Button(action: {
if deviceManager.isThisDeviceActive {
player.playPrevious()
} else {
Task { await deviceManager.sendCommand("previous") }
}
}) {
Image(systemName: "backward.fill").font(.title3)
}
.buttonStyle(.plain)
.foregroundColor((deviceManager.isThisDeviceActive ? player.canGoPrevious : true) ? .primary : .secondary)
.disabled(deviceManager.isThisDeviceActive && !player.canGoPrevious)
Button(action: {
if deviceManager.isThisDeviceActive {
player.togglePlayPause()
} else {
Task { await deviceManager.sendCommand(displayIsPlaying ? "pause" : "play") }
}
}) {
Image(systemName: displayIsPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 36))
}
.buttonStyle(.plain).foregroundColor(.accentColor)
.disabled(!displayHasTrack)
Button(action: {
if deviceManager.isThisDeviceActive {
player.playNext()
} else {
Task { await deviceManager.sendCommand("next") }
}
}) {
Image(systemName: "forward.fill").font(.title3)
}
.buttonStyle(.plain)
.foregroundColor((deviceManager.isThisDeviceActive ? player.canGoNext : true) ? .primary : .secondary)
.disabled(deviceManager.isThisDeviceActive && !player.canGoNext)
}
Spacer()
// Track actions
HStack(spacing: 10) {
Button(action: { toggleCurrentTrackLike() }) {
Image(systemName: isCurrentTrackLiked ? "heart.fill" : "heart")
.font(.system(size: 14))
}
.buttonStyle(.plain)
.foregroundColor(isCurrentTrackLiked ? .red : .secondary)
.disabled(player.currentTrack == nil)
Button(action: shareCurrentTrack) {
Image(systemName: "square.and.arrow.up").font(.system(size: 14))
}
.buttonStyle(.plain).foregroundColor(.secondary)
.disabled(player.currentTrack == nil)
Button(action: { showingTrackInfo.toggle() }) {
Image(systemName: "info.circle").font(.system(size: 14))
}
.buttonStyle(.plain).foregroundColor(.secondary)
.disabled(player.currentTrack == nil)
.popover(isPresented: $showingTrackInfo, arrowEdge: .bottom) {
if let track = player.currentTrack {
TrackInfoView(track: track)
}
}
Button(action: { showingQueue.toggle() }) {
Image(systemName: "list.bullet").font(.system(size: 14))
}
.buttonStyle(.plain)
.foregroundColor((player.canGoNext || player.canGoPrevious) ? .accentColor : .secondary)
.popover(isPresented: $showingQueue, arrowEdge: .bottom) {
QueueView(player: player)
}
}
Spacer()
// Volume
HStack(spacing: 6) {
let vol = deviceManager.isThisDeviceActive
? Double(player.volume)
: (deviceManager.remoteState?.volume ?? Double(player.volume))
Image(systemName: vol < 0.01 ? "speaker.slash" : vol < 0.5 ? "speaker.wave.1" : "speaker.wave.2")
.font(.caption).foregroundColor(.secondary).frame(width: 16)
Slider(
value: Binding(
get: { vol },
set: { newVol in
if deviceManager.isThisDeviceActive {
player.volume = Float(newVol)
} else {
Task { await deviceManager.sendCommand("volume", payload: ["volume": newVol]) }
}
}
),
in: 0...1
)
.frame(width: 64).tint(.secondary).controlSize(.mini)
}
}
}
}
// MARK: - Actions
private func openSettings() {
NSApp.sendAction(#selector(AppDelegate.openSettings), to: nil, from: nil)
}
private func playTrack(_ track: TrackCard) {
if deviceManager.isThisDeviceActive {
guard let auth = authManager.session?.tokens.authorizationHeader else { return }
player.play(track: track, authHeader: auth)
} else {
Task {
// Clear remote queue then play
await deviceManager.sendTrackCommand("queue_add_next", track: track)
await deviceManager.sendCommand("next")
}
}
}
private func addToQueue(_ track: TrackCard) {
if deviceManager.isThisDeviceActive {
player.addToQueue(track)
} else {
Task { await deviceManager.sendTrackCommand("queue_add_end", track: track) }
}
}
private func addToQueueNext(_ track: TrackCard) {
if deviceManager.isThisDeviceActive {
player.addToQueueNext(track)
} else {
Task { await deviceManager.sendTrackCommand("queue_add_next", track: track) }
}
}
private func applyTransferState(_ state: DevicePlaybackState) {
guard let baseUrl = authManager.session?.serverBaseUrl,
let auth = authManager.session?.tokens.authorizationHeader,
let trackInfo = state.track,
let track = trackInfo.toTrackCard(baseUrl: baseUrl) else { return }
player.play(track: track, authHeader: auth)
let queueTracks = state.tracks.compactMap { $0.toTrackCard(baseUrl: baseUrl) }
player.setQueue(queueTracks)
if state.positionSeconds > 1, state.durationSeconds > 0 {
let fraction = state.positionSeconds / state.durationSeconds
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
player.seek(to: fraction)
}
}
if state.paused {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
if player.isPlaying { player.togglePlayPause() }
}
}
}
private func toggleCurrentTrackLike() {
guard let track = player.currentTrack else { return }
isCurrentTrackLiked.toggle()
let id = track.id
let optimistic = isCurrentTrackLiked
Task {
do {
if let result = try await authManager.catalogService?.toggleLike(trackId: id) {
isCurrentTrackLiked = result
authManager.updateLikedState(trackId: id, liked: result)
}
} catch {
isCurrentTrackLiked = !optimistic
}
}
}
private func shareCurrentTrack() {
guard let track = player.currentTrack else { return }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString("\(track.title)\(track.artistNames)", forType: .string)
}
}
#Preview {
ContentView()
.environment(AuthManager())
}
+190
View File
@@ -0,0 +1,190 @@
//
// DeviceManager.swift
// furumi_macos
//
import Foundation
import Observation
@MainActor
@Observable
final class DeviceManager {
// User-Agent sent in every request product name recognized by server
static let userAgent: String = {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
return "FurumiMacOS/\(version) macOS"
}()
// Persistent device ID (alphanumeric + hyphens, accepted by server's normalize_device_id)
let deviceId: String
private(set) var devices: [PlayerDeviceInfo] = []
private(set) var activeDeviceId: String?
private(set) var remoteState: DevicePlaybackState?
private(set) var pendingTransferState: DevicePlaybackState?
private(set) var isConnected = false
/// true when this Mac is the active (playing) device or no active device is set
var isThisDeviceActive: Bool {
activeDeviceId == nil || activeDeviceId == deviceId
}
private var service: CatalogService?
private var pollTask: Task<Void, Never>?
private let decoder: JSONDecoder = {
let d = JSONDecoder()
d.keyDecodingStrategy = .convertFromSnakeCase
return d
}()
// MARK: - Init
init() {
if let saved = UserDefaults.standard.string(forKey: "device.id") {
deviceId = saved
} else {
let newId = UUID().uuidString // hyphens ok per normalize_device_id
UserDefaults.standard.set(newId, forKey: "device.id")
deviceId = newId
}
}
// MARK: - Lifecycle
func start(service: CatalogService, player: PlayerManager) {
self.service = service
stopPolling()
pollTask = Task { [weak self] in
while !Task.isCancelled {
await self?.doPoll(player: player)
try? await Task.sleep(nanoseconds: 3_000_000_000)
}
}
}
func stop() {
stopPolling()
service = nil
devices = []
activeDeviceId = nil
remoteState = nil
pendingTransferState = nil
isConnected = false
}
private func stopPolling() {
pollTask?.cancel()
pollTask = nil
}
// MARK: - Polling
private func doPoll(player: PlayerManager) async {
guard let service else { return }
let state = isThisDeviceActive ? DevicePlaybackState.from(player) : nil
do {
let data = try await service.devicePoll(
deviceId: deviceId,
userAgent: Self.userAgent,
playbackState: state
)
let response = try decoder.decode(DevicePollResponseDTO.self, from: data)
applyPollResponse(response, player: player)
} catch { }
}
private func applyPollResponse(_ response: DevicePollResponseDTO, player: PlayerManager) {
isConnected = true
devices = response.devices.map { $0.toDomain() }
activeDeviceId = response.activeDeviceId
if isThisDeviceActive {
remoteState = nil
} else {
remoteState = response.playbackState
}
for cmd in response.commands {
executeCommand(cmd, player: player)
}
}
// MARK: - Command execution
private func executeCommand(_ cmd: PlayerDeviceCommandDTO, player: PlayerManager) {
let p = cmd.payload
switch cmd.command {
case "transfer_state":
// Server set us as active and sent state to restore
pendingTransferState = DevicePlaybackState(
track: p.track,
tracks: p.tracks ?? [],
index: p.index ?? 0,
positionSeconds: p.positionSeconds ?? 0,
durationSeconds: p.durationSeconds ?? 0,
paused: p.paused ?? false,
shuffle: p.shuffle ?? false,
repeatMode: p.repeatMode ?? "none",
volume: p.volume ?? Double(player.volume),
updatedAtMs: p.updatedAtMs ?? 0
)
case "play":
if !player.isPlaying { player.togglePlayPause() }
case "pause":
if player.isPlaying { player.togglePlayPause() }
case "next":
if player.canGoNext { player.playNext() }
case "previous":
if player.canGoPrevious { player.playPrevious() }
case "seek":
if let pos = p.positionSeconds, player.duration > 0 {
player.seek(to: pos / player.duration)
}
case "volume":
if let vol = p.volume {
player.volume = Float(vol)
}
default: break
}
}
func clearPendingTransfer() {
pendingTransferState = nil
}
// MARK: - Actions
func selectDevice(_ targetId: String) async {
guard let service else { return }
do {
let data = try await service.deviceSelect(currentDeviceId: deviceId, targetDeviceId: targetId)
let response = try decoder.decode(DevicesResponseDTO.self, from: data)
devices = response.devices.map { $0.toDomain() }
activeDeviceId = response.activeDeviceId
if isThisDeviceActive { remoteState = nil }
} catch { }
}
func sendCommand(_ command: String, targetDeviceId: String? = nil, payload: [String: Any] = [:]) async {
guard let service else { return }
let payloadData = (try? JSONSerialization.data(withJSONObject: payload)) ?? Data("{}".utf8)
try? await service.deviceCommand(command: command, targetDeviceId: targetDeviceId, payloadData: payloadData)
}
func sendTrackCommand(_ command: String, track: TrackCard) async {
let trackDict: [String: Any] = [
"id": track.id,
"title": track.title,
"duration_seconds": Double(track.durationSeconds),
"stream_url": track.streamUrl,
"cover_url": track.coverUrl as Any,
"release_id": track.releaseId,
"release_title": track.releaseTitle,
"artists": track.artistNames.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.map { ["id": 0, "name": $0] as [String: Any] }
]
await sendCommand(command, payload: ["tracks": [trackDict]])
}
}
+178
View File
@@ -0,0 +1,178 @@
//
// DeviceModels.swift
// furumi_macos
//
import Foundation
// MARK: - Minimal track info for device state transfer
struct DeviceTrackInfo: Codable {
let id: Int64?
let title: String?
let durationSeconds: Double?
let coverUrl: String?
let streamUrl: String?
let releaseId: Int64?
let releaseTitle: String?
let artists: [DeviceArtistRef]?
var displayTitle: String { title ?? "Unknown" }
var displayArtists: String { artists?.map(\.name).joined(separator: ", ") ?? "" }
init(from track: TrackCard) {
id = track.id
title = track.title
durationSeconds = Double(track.durationSeconds)
coverUrl = track.coverUrl
streamUrl = track.streamUrl
releaseId = track.releaseId
releaseTitle = track.releaseTitle
let names = track.artistNames.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
artists = names.map { DeviceArtistRef(id: nil, name: $0) }
}
func toTrackCard(baseUrl: String) -> TrackCard? {
guard let id, let title, let streamUrl else { return nil }
return TrackCard(
id: id,
title: title,
trackNumber: nil,
discNumber: nil,
durationSeconds: Int((durationSeconds ?? 0).rounded()),
artistNames: artists?.map(\.name).joined(separator: ", ") ?? "",
coverUrl: coverUrl,
streamUrl: URLResolver.absolute(from: streamUrl, baseUrl: baseUrl),
releaseId: releaseId ?? 0,
releaseTitle: releaseTitle ?? "",
releaseYear: nil,
uploaderName: "",
audioFormat: nil, audioBitrate: nil, audioSampleRate: nil, audioBitDepth: nil,
fileSizeBytes: nil, lastfmListeners: nil, lastfmPlaycount: nil,
lastfmRating: nil, lastfmUpdatedAt: nil
)
}
}
struct DeviceArtistRef: Codable {
let id: Int64?
let name: String
}
// MARK: - Playback state sent/received
struct DevicePlaybackState: Codable {
var track: DeviceTrackInfo?
var tracks: [DeviceTrackInfo]
var index: Int
var positionSeconds: Double
var durationSeconds: Double
var paused: Bool
var shuffle: Bool
var repeatMode: String
var volume: Double
var updatedAtMs: Int64
// Accounts for elapsed time since last update when playing
var estimatedPositionSeconds: Double {
guard !paused, updatedAtMs > 0 else { return positionSeconds }
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
let elapsed = Double(max(0, nowMs - updatedAtMs)) / 1000.0
return min(positionSeconds + elapsed, durationSeconds > 0 ? durationSeconds : positionSeconds + elapsed)
}
static func from(_ player: PlayerManager) -> DevicePlaybackState? {
guard let track = player.currentTrack else { return nil }
return DevicePlaybackState(
track: DeviceTrackInfo(from: track),
tracks: player.queue.map { DeviceTrackInfo(from: $0) },
index: 0,
positionSeconds: player.currentTime,
durationSeconds: player.duration,
paused: !player.isPlaying,
shuffle: false,
repeatMode: "none",
volume: Double(player.volume),
updatedAtMs: 0
)
}
}
// MARK: - Command payload (flexible decoder for all command types)
struct CommandPayload: Decodable {
// transfer_state / queue fields
var track: DeviceTrackInfo?
var tracks: [DeviceTrackInfo]?
var index: Int?
var positionSeconds: Double?
var durationSeconds: Double?
var paused: Bool?
var volume: Double?
var shuffle: Bool?
var repeatMode: String?
var updatedAtMs: Int64?
}
// MARK: - DTOs from server
struct PlayerDeviceDTO: Decodable {
let id: String
let name: String
let kind: String
let isCurrent: Bool
let isActive: Bool
let lastSeenMs: Int64
func toDomain() -> PlayerDeviceInfo {
PlayerDeviceInfo(id: id, name: name, kind: kind,
isCurrent: isCurrent, isActive: isActive, lastSeenMs: lastSeenMs)
}
}
struct PlayerDeviceCommandDTO: Decodable {
let id: String
let command: String
let payload: CommandPayload
}
struct DevicesResponseDTO: Decodable {
let deviceId: String
let activeDeviceId: String?
let devices: [PlayerDeviceDTO]
let playbackState: DevicePlaybackState?
}
struct DevicePollResponseDTO: Decodable {
let deviceId: String
let activeDeviceId: String?
let devices: [PlayerDeviceDTO]
let commands: [PlayerDeviceCommandDTO]
let playbackState: DevicePlaybackState?
}
// MARK: - Domain model
struct PlayerDeviceInfo: Identifiable {
let id: String
let name: String
let kind: String
let isCurrent: Bool
let isActive: Bool
let lastSeenMs: Int64 // ms since last seen (from server)
var lastSeenText: String {
if lastSeenMs < 2_000 { return "just now" }
if lastSeenMs < 60_000 { return "\(lastSeenMs / 1000)s ago" }
return "\(lastSeenMs / 60_000)m ago"
}
var kindIcon: String {
switch kind {
case "phone": return "iphone"
case "tablet": return "ipad"
case "computer": return "laptopcomputer"
default: return "display"
}
}
}
+98
View File
@@ -0,0 +1,98 @@
//
// DevicesView.swift
// furumi_macos
//
import SwiftUI
struct DevicesView: View {
let deviceManager: DeviceManager
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Devices")
.font(.headline)
Spacer()
Button(action: { dismiss() }) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 16))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
Divider()
if deviceManager.devices.isEmpty {
Spacer()
Text("No devices online")
.foregroundColor(.secondary)
.font(.subheadline)
Spacer()
} else {
ScrollView {
VStack(spacing: 0) {
ForEach(deviceManager.devices) { device in
DeviceRow(device: device, isSelected: device.id == deviceManager.activeDeviceId)
.contentShape(Rectangle())
.onTapGesture {
guard !device.isActive else { return }
Task { await deviceManager.selectDevice(device.id) }
}
Divider()
}
}
}
}
}
.frame(width: 280, height: 260)
}
}
private struct DeviceRow: View {
let device: PlayerDeviceInfo
let isSelected: Bool
var body: some View {
HStack(spacing: 10) {
Image(systemName: device.kindIcon)
.font(.system(size: 18))
.frame(width: 28)
.foregroundColor(device.isActive ? .accentColor : .secondary)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(device.name)
.font(.subheadline)
.lineLimit(1)
if device.isCurrent {
Text("This device")
.font(.caption2)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.15))
.clipShape(Capsule())
}
}
Text(device.isActive ? "Active · \(device.lastSeenText)" : device.lastSeenText)
.font(.caption)
.foregroundColor(device.isActive ? .accentColor : .secondary)
}
Spacer()
if device.isActive {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.accentColor)
.font(.system(size: 16))
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(device.isActive ? Color.accentColor.opacity(0.05) : Color.clear)
}
}
+58
View File
@@ -0,0 +1,58 @@
//
// GlobalArtistsGridView.swift
// furumi_macos
//
import SwiftUI
struct GlobalArtistsGridView: View {
let artists: [ArtistCard]
var onSelect: (ArtistCard) -> Void
var onAppearLast: (Int64) -> Void
private let columns = [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)
]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(artists) { artist in
ArtistTileView(artist: artist)
.onTapGesture { onSelect(artist) }
.onAppear {
onAppearLast(artist.id)
}
}
}
.padding(12)
}
}
}
struct ArtistTileView: View {
let artist: ArtistCard
var body: some View {
VStack(spacing: 8) {
ImageView(urlString: artist.imageUrl, width: .infinity, height: 90, systemPlaceholder: "person.crop.square")
.frame(height: 90)
VStack(spacing: 2) {
Text(artist.name)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
Text("\(artist.releaseCount) releases • \(artist.trackCount) tracks")
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
.padding(.horizontal, 6)
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 12).fill(Color.secondary.opacity(0.08)))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Color.secondary.opacity(0.15)))
}
}
+69
View File
@@ -0,0 +1,69 @@
//
// GlobalArtistsViewModel.swift
// furumi_macos
//
import Foundation
import Observation
@MainActor
@Observable
final class GlobalArtistsViewModel {
private(set) var artists: [ArtistCard] = []
private(set) var isLoading = false
private(set) var hasMore = true
private(set) var error: String?
private var currentPage = 1
private let limit = 60
private var service: CatalogService
private let mine: Bool
init(service: CatalogService, mine: Bool) {
self.service = service
self.mine = mine
}
func reset(service: CatalogService) {
self.service = service
artists = []
isLoading = false
hasMore = true
currentPage = 1
error = nil
}
func loadFirstPage() async {
guard !isLoading else { return }
error = nil
isLoading = true
defer { isLoading = false }
do {
let page = try await service.artists(page: 1, limit: limit, mine: mine)
artists = page.items
currentPage = 1
hasMore = page.hasMore
} catch {
self.error = error.localizedDescription
artists = []
hasMore = false
}
}
func loadNextPageIfNeeded(lastVisibleID: Int64) async {
guard hasMore, !isLoading else { return }
guard artists.last?.id == lastVisibleID else { return }
isLoading = true
defer { isLoading = false }
do {
let next = currentPage + 1
let page = try await service.artists(page: next, limit: limit, mine: mine)
artists.append(contentsOf: page.items)
currentPage = next
hasMore = page.hasMore
} catch {
self.error = error.localizedDescription
hasMore = false
}
}
}
+71
View File
@@ -0,0 +1,71 @@
//
// ImageView.swift
// furumi_macos
//
import SwiftUI
struct ImageView: View {
let urlString: String?
let width: CGFloat
let height: CGFloat
let systemPlaceholder: String
@Environment(AuthManager.self) private var authManager
@State private var loadedImage: Image?
var body: some View {
Group {
if let loadedImage {
loadedImage
.resizable()
.scaledToFill()
.sized(width: width, height: height)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 10))
} else {
placeholder
.sized(width: width, height: height)
}
}
.task(id: urlString) {
await loadImage()
}
}
private func loadImage() async {
loadedImage = nil
guard let urlString, let url = URL(string: urlString) else { return }
var req = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad)
if let auth = authManager.session?.tokens.authorizationHeader {
req.setValue(auth, forHTTPHeaderField: "Authorization")
}
guard let (data, _) = try? await URLSession.shared.data(for: req),
let nsImage = NSImage(data: data) else { return }
loadedImage = Image(nsImage: nsImage)
}
@ViewBuilder
private var placeholder: some View {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color.secondary.opacity(0.12))
Image(systemName: systemPlaceholder)
.foregroundColor(.accentColor)
}
}
}
private extension View {
@ViewBuilder
func sized(width: CGFloat, height: CGFloat) -> some View {
if width.isFinite {
self.frame(width: width, height: height)
} else {
self.frame(maxWidth: .infinity, minHeight: height, maxHeight: height)
}
}
}
+37
View File
@@ -0,0 +1,37 @@
<?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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSUIElement</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>furumi</string>
</array>
<key>CFBundleURLName</key>
<string>cy.hexor.furumi</string>
</dict>
</array>
</dict>
</plist>
+266
View File
@@ -0,0 +1,266 @@
//
// PlayerManager.swift
// furumi_macos
//
import AppKit
import AVFoundation
import MediaPlayer
import Observation
import SwiftUI
@MainActor
@Observable
final class PlayerManager {
private(set) var currentTrack: TrackCard?
private(set) var isPlaying = false
private(set) var currentTime: Double = 0
private(set) var duration: Double = 0
private(set) var queue: [TrackCard] = []
private(set) var history: [TrackCard] = []
var volume: Float = {
let v = UserDefaults.standard.float(forKey: "player.volume")
return v > 0 ? v : 1.0
}() {
didSet {
player?.volume = volume
UserDefaults.standard.set(volume, forKey: "player.volume")
}
}
var progress: Double {
duration > 0 ? min(currentTime / duration, 1) : 0
}
var canGoNext: Bool { !queue.isEmpty }
var canGoPrevious: Bool { !history.isEmpty }
var formattedCurrentTime: String { formatTime(currentTime) }
private var player: AVPlayer?
private var timeObserver: Any?
private var lastAuthHeader = ""
init() {
setupRemoteCommands()
}
// MARK: - Playback
// Core playback does not modify history or queue
private func playCore(track: TrackCard, authHeader: String) {
guard let url = URL(string: track.streamUrl) else { return }
lastAuthHeader = authHeader
clearObserver()
let asset = AVURLAsset(url: url, options: [
"AVURLAssetHTTPHeaderFieldsKey": ["Authorization": authHeader]
])
let item = AVPlayerItem(asset: asset)
NotificationCenter.default.addObserver(
forName: AVPlayerItem.didPlayToEndTimeNotification,
object: item,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
guard let self else { return }
if !self.queue.isEmpty {
self.playNext()
} else {
self.isPlaying = false
self.updateNowPlayingPlaybackState()
}
}
}
if let player {
player.replaceCurrentItem(with: item)
} else {
player = AVPlayer(playerItem: item)
}
player?.volume = volume
player?.play()
isPlaying = true
currentTrack = track
currentTime = 0
duration = Double(track.durationSeconds)
addObserver()
updateNowPlayingInfo(track: track)
}
func play(track: TrackCard, authHeader: String) {
if let current = currentTrack {
history.append(current)
if history.count > 50 { history.removeFirst() }
}
playCore(track: track, authHeader: authHeader)
}
func togglePlayPause() {
guard let player else { return }
if isPlaying {
player.pause()
isPlaying = false
} else {
player.play()
isPlaying = true
}
updateNowPlayingPlaybackState()
}
func playNext() {
guard !queue.isEmpty else { return }
if let current = currentTrack {
history.append(current)
if history.count > 50 { history.removeFirst() }
}
let next = queue.removeFirst()
playCore(track: next, authHeader: lastAuthHeader)
}
func playPrevious() {
guard !history.isEmpty else { return }
if let current = currentTrack { queue.insert(current, at: 0) }
let prev = history.removeLast()
playCore(track: prev, authHeader: lastAuthHeader)
}
func playFromHistory(at index: Int) {
guard index < history.count else { return }
let track = history[index]
var restored = Array(history[(index + 1)...])
if let current = currentTrack { restored.append(current) }
restored.append(contentsOf: queue)
history = Array(history[..<index])
queue = restored
playCore(track: track, authHeader: lastAuthHeader)
}
func setQueue(_ tracks: [TrackCard]) {
queue = tracks
}
func addToQueue(_ track: TrackCard) {
queue.append(track)
}
func addToQueueNext(_ track: TrackCard) {
queue.insert(track, at: 0)
}
func removeFromQueue(at offsets: IndexSet) {
queue.remove(atOffsets: offsets)
}
func seek(to fraction: Double) {
guard let player, duration > 0 else { return }
let seconds = fraction * duration
player.seek(to: CMTime(seconds: seconds, preferredTimescale: 600))
currentTime = seconds
updateNowPlayingPlaybackState()
}
// MARK: - Media keys (MPRemoteCommandCenter)
private func setupRemoteCommands() {
let center = MPRemoteCommandCenter.shared()
center.playCommand.addTarget { [weak self] _ in
guard let self, !self.isPlaying else { return .commandFailed }
self.togglePlayPause()
return .success
}
center.pauseCommand.addTarget { [weak self] _ in
guard let self, self.isPlaying else { return .commandFailed }
self.togglePlayPause()
return .success
}
center.togglePlayPauseCommand.addTarget { [weak self] _ in
guard let self else { return .commandFailed }
self.togglePlayPause()
return .success
}
center.nextTrackCommand.isEnabled = true
center.nextTrackCommand.addTarget { [weak self] _ in
guard let self, self.canGoNext else { return .commandFailed }
self.playNext()
return .success
}
center.previousTrackCommand.addTarget { [weak self] _ in
guard let self else { return .commandFailed }
if self.canGoPrevious {
self.playPrevious()
} else {
self.seek(to: 0)
}
return .success
}
center.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let self,
let e = event as? MPChangePlaybackPositionCommandEvent,
self.duration > 0 else { return .commandFailed }
self.seek(to: e.positionTime / self.duration)
return .success
}
}
// MARK: - Now Playing info
private func updateNowPlayingInfo(track: TrackCard) {
var info: [String: Any] = [
MPMediaItemPropertyTitle: track.title,
MPMediaItemPropertyArtist: track.artistNames,
MPMediaItemPropertyPlaybackDuration: Double(track.durationSeconds),
MPNowPlayingInfoPropertyElapsedPlaybackTime: 0,
MPNowPlayingInfoPropertyPlaybackRate: 1.0
]
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
// Load cover art asynchronously
if let urlString = track.coverUrl, let url = URL(string: urlString) {
Task {
guard let (data, _) = try? await URLSession.shared.data(from: url),
let nsImage = NSImage(data: data) else { return }
let artwork = MPMediaItemArtwork(boundsSize: CGSize(width: 300, height: 300)) { _ in nsImage }
info[MPMediaItemPropertyArtwork] = artwork
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
}
}
private func updateNowPlayingPlaybackState() {
guard var info = MPNowPlayingInfoCenter.default().nowPlayingInfo else { return }
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
// MARK: - Private helpers
private func addObserver() {
guard let player else { return }
let interval = CMTime(seconds: 0.25, preferredTimescale: 600)
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
guard let self else { return }
self.currentTime = time.seconds
if let d = self.player?.currentItem?.duration, d.isNumeric, d.seconds > 0 {
self.duration = d.seconds
}
self.isPlaying = (self.player?.rate ?? 0) > 0
}
}
private func clearObserver() {
if let observer = timeObserver, let player {
player.removeTimeObserver(observer)
}
timeObserver = nil
}
private func formatTime(_ seconds: Double) -> String {
guard seconds.isFinite, seconds >= 0 else { return "--:--" }
let s = Int(seconds)
return String(format: "%d:%02d", s / 60, s % 60)
}
}
+204
View File
@@ -0,0 +1,204 @@
//
// PlaylistScreen.swift
// furumi_macos
//
import SwiftUI
struct PlaylistScreen: View {
let playlistId: Int64
let titleFallback: String
var onBack: () -> Void
var onPlayTrack: (TrackCard) -> Void
var onAddToQueue: (TrackCard) -> Void
var onPlayNext: (TrackCard) -> Void
@Environment(AuthManager.self) private var authManager
@State private var detail: PlaylistDetail?
@State private var isLoading = false
@State private var error: String?
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 8) {
Button(action: onBack) {
Image(systemName: "chevron.left")
}
.buttonStyle(.plain)
Text(detail?.card.title ?? titleFallback)
.font(.headline)
.lineLimit(1)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
Divider()
if isLoading && detail == nil {
Spacer(); ProgressView(); Spacer()
} else if let error {
Spacer()
Text(error).foregroundColor(.secondary).font(.caption).padding()
Spacer()
} else if let detail {
playlistContent(detail)
}
}
.task {
guard let service = authManager.catalogService else { return }
isLoading = true
error = nil
do {
detail = try await service.playlistDetail(id: playlistId)
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}
@ViewBuilder
private func playlistContent(_ detail: PlaylistDetail) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(Color.accentColor.opacity(0.15))
.frame(width: 72, height: 72)
Image(systemName: detail.card.kind == "likes" ? "heart.fill" : "music.note.list")
.font(.system(size: 28))
.foregroundColor(.accentColor)
}
VStack(alignment: .leading, spacing: 4) {
Text(detail.card.title)
.font(.title3).fontWeight(.semibold).lineLimit(2)
Text("\(detail.card.trackCount) tracks")
.font(.caption).foregroundColor(.secondary)
}
Spacer()
}
.padding(.horizontal, 12)
HStack(spacing: 8) {
Button(action: {
guard !detail.tracks.isEmpty else { return }
onPlayTrack(detail.tracks[0])
detail.tracks.dropFirst().forEach { onAddToQueue($0) }
}) {
Label("Play All", systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
}
.controlSize(.regular)
.padding(.horizontal, 12)
Divider()
VStack(spacing: 0) {
ForEach(Array(detail.tracks.enumerated()), id: \.element.id) { idx, track in
PlaylistTrackRow(
index: idx + 1,
track: track,
onPlay: { onPlayTrack(track) },
onPlayNext: { onPlayNext(track) },
onAddToQueue: { onAddToQueue(track) }
)
Divider().padding(.leading, 52)
}
}
}
.padding(.vertical, 8)
}
}
}
private struct PlaylistTrackRow: View {
let index: Int
let track: TrackCard
var onPlay: () -> Void
var onPlayNext: () -> Void
var onAddToQueue: () -> Void
@Environment(AuthManager.self) private var authManager
@State private var isLiked = false
@State private var showInfo = false
var body: some View {
HStack(spacing: 10) {
Text("\(index)")
.font(.caption).foregroundColor(.secondary)
.frame(width: 24, alignment: .trailing)
ImageView(urlString: track.coverUrl, width: 32, height: 32, systemPlaceholder: "music.note")
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) {
Text(track.title).font(.subheadline).lineLimit(1)
if !track.artistNames.isEmpty {
Text(track.artistNames).font(.caption).foregroundColor(.secondary).lineLimit(1)
}
}
Spacer()
Text(formatDuration(track.durationSeconds))
.font(.caption).foregroundColor(.secondary)
Button(action: { toggleLike() }) {
Image(systemName: isLiked ? "heart.fill" : "heart")
.font(.system(size: 13))
}
.buttonStyle(.plain)
.foregroundColor(isLiked ? .red : .secondary)
Menu {
Button { onPlayNext() } label: { Label("Play Next", systemImage: "text.insert") }
Button { onAddToQueue() } label: { Label("Add to Queue", systemImage: "text.badge.plus") }
Divider()
Button { shareTrack() } label: { Label("Share", systemImage: "square.and.arrow.up") }
Button { showInfo = true } label: { Label("Track Info", systemImage: "info.circle") }
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 13))
.frame(width: 20, height: 20)
}
.menuStyle(.borderlessButton)
.fixedSize()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.contentShape(Rectangle())
.onTapGesture { onPlay() }
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
TrackInfoView(track: track)
}
}
private func toggleLike() {
isLiked.toggle()
let id = track.id
Task {
do {
let result = try await authManager.catalogService?.toggleLike(trackId: id)
if let result {
isLiked = result
authManager.updateLikedState(trackId: id, liked: result)
}
} catch {
isLiked.toggle()
}
}
}
private func shareTrack() {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString("\(track.title)\(track.artistNames)", forType: .string)
}
private func formatDuration(_ s: Int) -> String {
String(format: "%d:%02d", s / 60, s % 60)
}
}
+60
View File
@@ -0,0 +1,60 @@
//
// PlaylistsGridView.swift
// furumi_macos
//
import SwiftUI
struct PlaylistsGridView: View {
let playlists: [PlaylistCard]
var onSelect: (PlaylistCard) -> Void
private let columns = [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)
]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(playlists) { playlist in
PlaylistTileView(playlist: playlist)
.onTapGesture { onSelect(playlist) }
}
}
.padding(12)
}
}
}
struct PlaylistTileView: View {
let playlist: PlaylistCard
var body: some View {
VStack(spacing: 8) {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(Color.accentColor.opacity(0.15))
Image(systemName: playlist.kind == "likes" ? "heart.fill" : "music.note.list")
.font(.system(size: 28))
.foregroundColor(.accentColor)
}
.frame(height: 90)
VStack(spacing: 2) {
Text(playlist.title)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
Text("\(playlist.trackCount) tracks")
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
.padding(.horizontal, 6)
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 12).fill(Color.secondary.opacity(0.08)))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Color.secondary.opacity(0.15)))
}
}
+37
View File
@@ -0,0 +1,37 @@
//
// PlaylistsViewModel.swift
// furumi_macos
//
import Foundation
import Observation
@MainActor
@Observable
final class PlaylistsViewModel {
private(set) var playlists: [PlaylistCard] = []
private(set) var isLoading = false
private(set) var error: String?
private var service: CatalogService
init(service: CatalogService) { self.service = service }
func reset(service: CatalogService) {
self.service = service
playlists = []
isLoading = false
error = nil
}
func load() async {
guard !isLoading else { return }
isLoading = true
error = nil
defer { isLoading = false }
do {
playlists = try await service.playlists()
} catch {
self.error = error.localizedDescription
}
}
}
+102
View File
@@ -0,0 +1,102 @@
//
// QueueView.swift
// furumi_macos
//
import SwiftUI
struct QueueView: View {
let player: PlayerManager
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Queue")
.font(.headline)
Spacer()
let total = player.history.count + player.queue.count
if total > 0 {
Text("\(total) tracks")
.font(.caption).foregroundColor(.secondary)
}
Button(action: { dismiss() }) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 16))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
Divider()
if player.history.isEmpty && player.queue.isEmpty {
Spacer()
Text("Queue is empty")
.foregroundColor(.secondary)
.font(.subheadline)
Spacer()
} else {
List {
if !player.history.isEmpty {
Section("Previously Played") {
ForEach(player.history.indices.reversed(), id: \.self) { idx in
let track = player.history[idx]
QueueTrackRow(track: track, label: nil, dimmed: true)
.onTapGesture { player.playFromHistory(at: idx) }
}
}
}
if !player.queue.isEmpty {
Section("Up Next") {
ForEach(Array(player.queue.enumerated()), id: \.element.id) { idx, track in
QueueTrackRow(track: track, label: "\(idx + 1)", dimmed: false)
}
.onDelete { player.removeFromQueue(at: $0) }
}
}
}
.listStyle(.plain)
}
}
.frame(width: 300, height: 360)
}
}
private struct QueueTrackRow: View {
let track: TrackCard
let label: String?
let dimmed: Bool
var body: some View {
HStack(spacing: 10) {
if let label {
Text(label)
.font(.caption).foregroundColor(.secondary)
.frame(width: 20, alignment: .trailing)
} else {
Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
.font(.caption).foregroundColor(.secondary)
.frame(width: 20)
}
ImageView(urlString: track.coverUrl, width: 34, height: 34, systemPlaceholder: "music.note")
.frame(width: 34, height: 34)
.opacity(dimmed ? 0.6 : 1)
VStack(alignment: .leading, spacing: 2) {
Text(track.title).font(.subheadline).lineLimit(1)
.foregroundColor(dimmed ? .secondary : .primary)
Text(track.artistNames).font(.caption).foregroundColor(.secondary).lineLimit(1)
}
Spacer()
Text(formatDuration(track.durationSeconds))
.font(.caption).foregroundColor(.secondary)
}
.padding(.vertical, 2)
}
private func formatDuration(_ s: Int) -> String {
String(format: "%d:%02d", s / 60, s % 60)
}
}
+40
View File
@@ -0,0 +1,40 @@
//
// ReleaseDetailViewModel.swift
// furumi_macos
//
import Foundation
import Observation
@MainActor
@Observable
final class ReleaseDetailViewModel {
private var service: CatalogService
private(set) var detail: ReleaseDetail?
private(set) var isLoading = false
private(set) var error: String?
init(service: CatalogService) {
self.service = service
}
func reset(service: CatalogService) {
self.service = service
detail = nil
isLoading = false
error = nil
}
func load(releaseId: Int64) async {
guard !isLoading else { return }
isLoading = true
error = nil
defer { isLoading = false }
do {
detail = try await service.releaseDetail(id: releaseId)
} catch {
self.error = (error as NSError).localizedDescription
self.detail = nil
}
}
}
+202
View File
@@ -0,0 +1,202 @@
//
// ReleaseScreen.swift
// furumi_macos
//
import SwiftUI
struct ReleaseScreen: View {
let releaseId: Int64
let titleFallback: String
var onBack: () -> Void
var onPlayTrack: (TrackCard) -> Void
var onAddToQueue: (TrackCard) -> Void
var onPlayNext: (TrackCard) -> Void
@Environment(AuthManager.self) private var authManager
@State private var vm: ReleaseDetailViewModel?
var body: some View {
VStack(spacing: 0) {
// Back + title
HStack(spacing: 8) {
Button(action: onBack) {
Image(systemName: "chevron.left")
}
.buttonStyle(.plain)
Text(vm?.detail?.card.title ?? titleFallback)
.font(.headline)
.lineLimit(1)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
Divider()
if let vm {
if vm.isLoading && vm.detail == nil {
Spacer()
ProgressView()
Spacer()
} else if let error = vm.error {
Spacer()
Text(error).foregroundColor(.secondary).font(.caption).padding()
Spacer()
} else if let detail = vm.detail {
releaseContent(detail)
}
} else {
Spacer()
ProgressView()
Spacer()
}
}
.task {
guard let service = authManager.catalogService else { return }
let newVM = ReleaseDetailViewModel(service: service)
vm = newVM
await newVM.load(releaseId: releaseId)
}
}
@ViewBuilder
private func releaseContent(_ detail: ReleaseDetail) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
// Release header
HStack(spacing: 12) {
ImageView(
urlString: detail.card.coverUrl,
width: 72, height: 72,
systemPlaceholder: "opticaldisc.fill"
)
VStack(alignment: .leading, spacing: 4) {
Text(detail.card.title)
.font(.title3).fontWeight(.semibold).lineLimit(2)
Text(detail.artists.map(\.name).joined(separator: ", "))
.font(.subheadline).foregroundColor(.secondary).lineLimit(1)
Text("\(detail.card.year > 0 ? String(detail.card.year) : "")\(detail.card.type.capitalized)")
.font(.caption).foregroundColor(.secondary)
}
Spacer()
}
.padding(.horizontal, 12)
// Play all / Queue buttons
HStack(spacing: 8) {
Button(action: {
guard !detail.tracks.isEmpty else { return }
onPlayTrack(detail.tracks[0])
detail.tracks.dropFirst().forEach { onAddToQueue($0) }
}) {
Label("Play All", systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
}
.controlSize(.regular)
.padding(.horizontal, 12)
Divider()
// Track list
VStack(spacing: 0) {
ForEach(Array(detail.tracks.enumerated()), id: \.element.id) { idx, track in
ReleaseTrackRow(index: idx + 1, track: track,
onPlay: { onPlayTrack(track) },
onPlayNext: { onPlayNext(track) },
onAddToQueue: { onAddToQueue(track) })
Divider().padding(.leading, 52)
}
}
}
.padding(.vertical, 8)
}
}
}
private struct ReleaseTrackRow: View {
let index: Int
let track: TrackCard
var onPlay: () -> Void
var onPlayNext: () -> Void
var onAddToQueue: () -> Void
@Environment(AuthManager.self) private var authManager
@State private var isLiked = false
@State private var showInfo = false
var body: some View {
HStack(spacing: 10) {
Text("\(index)")
.font(.caption).foregroundColor(.secondary)
.frame(width: 24, alignment: .trailing)
VStack(alignment: .leading, spacing: 2) {
Text(track.title).font(.subheadline).lineLimit(1)
if !track.artistNames.isEmpty {
Text(track.artistNames).font(.caption).foregroundColor(.secondary).lineLimit(1)
}
}
Spacer()
Text(formatDuration(track.durationSeconds))
.font(.caption).foregroundColor(.secondary)
Button(action: { toggleLike() }) {
Image(systemName: isLiked ? "heart.fill" : "heart")
.font(.system(size: 13))
}
.buttonStyle(.plain)
.foregroundColor(isLiked ? .red : .secondary)
Menu {
Button { onPlayNext() } label: { Label("Play Next", systemImage: "text.insert") }
Button { onAddToQueue() } label: { Label("Add to Queue", systemImage: "text.badge.plus") }
Divider()
Button { shareTrack() } label: { Label("Share", systemImage: "square.and.arrow.up") }
Button { showInfo = true } label: { Label("Track Info", systemImage: "info.circle") }
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 13))
.frame(width: 20, height: 20)
}
.menuStyle(.borderlessButton)
.fixedSize()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.contentShape(Rectangle())
.onTapGesture { onPlay() }
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
TrackInfoView(track: track)
}
}
private func toggleLike() {
isLiked.toggle()
let id = track.id
Task {
do {
let result = try await authManager.catalogService?.toggleLike(trackId: id)
if let result {
isLiked = result
authManager.updateLikedState(trackId: id, liked: result)
}
} catch {
isLiked.toggle()
}
}
}
private func shareTrack() {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString("\(track.title)\(track.artistNames)", forType: .string)
}
private func formatDuration(_ s: Int) -> String {
String(format: "%d:%02d", s / 60, s % 60)
}
}
+169
View File
@@ -0,0 +1,169 @@
//
// SearchResultsView.swift
// furumi_macos
//
import SwiftUI
struct SearchResultsView: View {
let results: SearchResult
var onSelectArtist: (ArtistCard) -> Void
var onSelectRelease: (ReleaseCard) -> Void
var onSelectTrack: (TrackCard) -> Void
var onAddToQueue: (TrackCard) -> Void
var onPlayNext: (TrackCard) -> Void
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if !results.artists.isEmpty {
Text("Artists")
.font(.headline)
.padding(.horizontal, 12)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
ForEach(results.artists) { artist in
ArtistTileView(artist: artist)
.onTapGesture { onSelectArtist(artist) }
}
}
.padding(.horizontal, 12)
}
if !results.releases.isEmpty {
Text("Releases")
.font(.headline)
.padding(.horizontal, 12)
VStack(spacing: 0) {
ForEach(results.releases) { rel in
ReleaseRowSearch(release: rel)
.onTapGesture { onSelectRelease(rel) }
Divider()
}
}
.padding(.horizontal, 12)
}
if !results.tracks.isEmpty {
Text("Tracks")
.font(.headline)
.padding(.horizontal, 12)
VStack(spacing: 0) {
ForEach(results.tracks) { tr in
TrackRowSearch(track: tr,
onPlay: { onSelectTrack(tr) },
onPlayNext: { onPlayNext(tr) },
onAddToQueue: { onAddToQueue(tr) })
Divider()
}
}
.padding(.horizontal, 12)
}
}
.padding(.vertical, 8)
}
}
}
private struct ReleaseRowSearch: View {
let release: ReleaseCard
var body: some View {
HStack(spacing: 12) {
ImageView(urlString: release.coverUrl, width: 40, height: 40, systemPlaceholder: "opticaldisc")
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 2) {
Text(release.title)
.font(.subheadline)
.lineLimit(1)
Text("\(release.year > 0 ? String(release.year) : "")\(release.type)")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
.padding(.vertical, 6)
}
}
private struct TrackRowSearch: View {
let track: TrackCard
var onPlay: () -> Void
var onPlayNext: () -> Void
var onAddToQueue: () -> Void
@Environment(AuthManager.self) private var authManager
@State private var isLiked = false
@State private var showInfo = false
var body: some View {
HStack(spacing: 10) {
ImageView(urlString: track.coverUrl, width: 40, height: 40, systemPlaceholder: "music.note")
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(track.title).font(.subheadline).lineLimit(1)
Spacer()
Text(formatDuration(track.durationSeconds)).font(.caption).foregroundColor(.secondary)
}
Text(track.artistNames).font(.caption).foregroundColor(.secondary).lineLimit(1)
}
Button(action: { toggleLike() }) {
Image(systemName: isLiked ? "heart.fill" : "heart")
.font(.system(size: 13))
}
.buttonStyle(.plain)
.foregroundColor(isLiked ? .red : .secondary)
Menu {
Button { onPlayNext() } label: { Label("Play Next", systemImage: "text.insert") }
Button { onAddToQueue() } label: { Label("Add to Queue", systemImage: "text.badge.plus") }
Divider()
Button { shareTrack() } label: { Label("Share", systemImage: "square.and.arrow.up") }
Button { showInfo = true } label: { Label("Track Info", systemImage: "info.circle") }
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 13))
.frame(width: 20, height: 20)
}
.menuStyle(.borderlessButton)
.fixedSize()
}
.padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture { onPlay() }
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
TrackInfoView(track: track)
}
}
private func toggleLike() {
isLiked.toggle()
let id = track.id
Task {
do {
let result = try await authManager.catalogService?.toggleLike(trackId: id)
if let result {
isLiked = result
authManager.updateLikedState(trackId: id, liked: result)
}
} catch {
isLiked.toggle()
}
}
}
private func shareTrack() {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString("\(track.title)\(track.artistNames)", forType: .string)
}
private func formatDuration(_ seconds: Int) -> String {
String(format: "%d:%02d", seconds / 60, seconds % 60)
}
}
+64
View File
@@ -0,0 +1,64 @@
//
// SearchViewModel.swift
// furumi_macos
//
import Foundation
import Observation
@MainActor
@Observable
final class SearchViewModel {
private var service: CatalogService
private(set) var results: SearchResult?
private(set) var isSearching = false
private(set) var error: String?
var query: String = "" {
didSet { debounceSearch() }
}
private var pendingTask: Task<Void, Never>?
private let limit = 20
init(service: CatalogService) {
self.service = service
}
func reset(service: CatalogService) {
self.service = service
self.results = nil
self.isSearching = false
self.error = nil
self.query = ""
pendingTask?.cancel()
pendingTask = nil
}
private func debounceSearch() {
pendingTask?.cancel()
let q = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !q.isEmpty else { results = nil; error = nil; return }
pendingTask = Task { [weak self] in
do {
try await Task.sleep(nanoseconds: 300_000_000) // 300ms
} catch {
return // cancelled не вызываем поиск
}
await self?.performSearch(q)
}
}
private func performSearch(_ q: String) async {
guard !q.isEmpty else { return }
isSearching = true
error = nil
defer { isSearching = false }
do {
results = try await service.search(query: q, limit: limit)
} catch {
self.error = (error as NSError).localizedDescription
self.results = nil
}
}
}
+144
View File
@@ -0,0 +1,144 @@
//
// SettingsView.swift
// furumi_macos
//
import SwiftUI
struct SettingsView: View {
@Environment(AuthManager.self) private var authManager
@State private var serverURL: String = "https://music.hexor.cy"
@State private var username: String = ""
@State private var password: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 24) {
serverSection
Divider()
authSection
Spacer()
}
.padding(24)
.frame(width: 360)
.onAppear {
if let session = authManager.session {
serverURL = session.serverBaseUrl
}
}
}
// MARK: - Server
@ViewBuilder
private var serverSection: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Server", systemImage: "server.rack")
.font(.headline)
if authManager.session != nil {
Text(serverURL)
.foregroundColor(.secondary)
.font(.subheadline)
} else {
TextField("Server URL", text: $serverURL)
.textFieldStyle(.roundedBorder)
}
}
}
// MARK: - Auth
@ViewBuilder
private var authSection: some View {
VStack(alignment: .leading, spacing: 12) {
Label("Authentication", systemImage: "person.circle")
.font(.headline)
if let session = authManager.session {
loggedInRow(session: session)
} else {
loginForm
}
if let error = authManager.error {
Text(error)
.foregroundColor(.red)
.font(.caption)
.fixedSize(horizontal: false, vertical: true)
}
}
}
@ViewBuilder
private func loggedInRow(session: AuthSession) -> some View {
HStack(spacing: 12) {
Image(systemName: "person.crop.circle.fill")
.font(.title2)
.foregroundColor(.accentColor)
VStack(alignment: .leading, spacing: 2) {
Text(session.user.name)
.fontWeight(.medium)
Text(session.user.role)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button("Sign Out") {
Task { await authManager.logout() }
}
.buttonStyle(.bordered)
.disabled(authManager.isLoading)
}
.padding(12)
.background(Color.accentColor.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
@ViewBuilder
private var loginForm: some View {
VStack(spacing: 8) {
TextField("Username", text: $username)
.textFieldStyle(.roundedBorder)
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
Button(action: {
Task { await authManager.login(serverUrl: serverURL, username: username, password: password) }
}) {
HStack {
Spacer()
if authManager.isLoading {
ProgressView().controlSize(.small)
} else {
Text("Sign In")
}
Spacer()
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(authManager.isLoading)
Button(action: { authManager.startSSO(serverUrl: serverURL) }) {
HStack {
Spacer()
Image(systemName: "globe")
Text("SSO Login")
Spacer()
}
}
.buttonStyle(.bordered)
.controlSize(.large)
.disabled(authManager.isLoading)
}
}
}
#Preview {
SettingsView()
.environment(AuthManager())
}
+144
View File
@@ -0,0 +1,144 @@
//
// TrackInfoView.swift
// furumi_macos
//
import SwiftUI
struct TrackInfoView: View {
let track: TrackCard
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
// Header
HStack(spacing: 12) {
ImageView(urlString: track.coverUrl, width: 64, height: 64, systemPlaceholder: "music.note")
VStack(alignment: .leading, spacing: 4) {
Text(track.title).font(.headline).lineLimit(2)
if !track.artistNames.isEmpty {
Text(track.artistNames).font(.subheadline).foregroundColor(.secondary).lineLimit(1)
}
if !track.releaseTitle.isEmpty {
Text(track.releaseTitle).font(.caption).foregroundColor(.secondary).lineLimit(1)
}
}
}
Divider()
// Basic info
section("Track") {
if let n = track.trackNumber {
row("Track #", String(n))
}
if let d = track.discNumber, d > 1 {
row("Disc #", String(d))
}
row("Duration", formatDuration(track.durationSeconds))
if let y = track.releaseYear, y > 0 {
row("Release Year", String(y))
}
if !track.uploaderName.isEmpty {
row("Uploader", track.uploaderName)
}
}
// Audio tech info
if track.audioFormat != nil || track.audioBitrate != nil {
Divider()
section("Audio") {
if let fmt = track.audioFormat {
row("Format", fmt.uppercased())
}
if let br = track.audioBitrate {
row("Bitrate", "\(br) kbps")
}
if let sr = track.audioSampleRate {
row("Sample Rate", "\(sr) Hz")
}
if let bd = track.audioBitDepth {
row("Bit Depth", "\(bd)-bit")
}
if let bytes = track.fileSizeBytes {
row("Size", formatSize(bytes))
}
}
}
// Last.fm stats
if track.lastfmListeners != nil || track.lastfmPlaycount != nil {
Divider()
section("Last.fm") {
if let rating = track.lastfmRating {
row("Popularity", String(Int(rating)))
}
if let listeners = track.lastfmListeners {
row("Listeners", formatNumber(listeners))
}
if let plays = track.lastfmPlaycount {
row("Plays", formatNumber(plays))
}
if let updated = track.lastfmUpdatedAt {
row("Updated", formatDate(updated))
}
}
}
}
.padding(16)
}
.frame(width: 260, height: 380)
}
@ViewBuilder
private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(.secondary)
.textCase(.uppercase)
content()
}
}
private func row(_ label: String, _ value: String) -> some View {
HStack(alignment: .top) {
Text(label)
.font(.caption)
.foregroundColor(.secondary)
.frame(width: 90, alignment: .leading)
Text(value)
.font(.caption)
.lineLimit(2)
Spacer()
}
}
private func formatDuration(_ s: Int) -> String {
String(format: "%d:%02d", s / 60, s % 60)
}
private func formatSize(_ bytes: Int64) -> String {
let mb = Double(bytes) / 1_048_576
return String(format: "%.1f MB", mb)
}
private func formatNumber(_ n: Int64) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter.string(from: NSNumber(value: n)) ?? String(n)
}
private func formatDate(_ iso: String) -> String {
let input = ISO8601DateFormatter()
let output = DateFormatter()
output.dateStyle = .medium
output.timeStyle = .none
if let date = input.date(from: iso) {
return output.string(from: date)
}
return String(iso.prefix(10))
}
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
+102 -5
View File
@@ -2,16 +2,113 @@
// furumi_macosApp.swift
// furumi_macos
//
// Created by Alexandr Bogomiakov on 08/06/2026.
//
import SwiftUI
import AppKit
@main
struct furumi_macosApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
Settings { EmptyView() }
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem!
var popover: NSPopover!
var settingsWindow: NSWindow?
let authManager = AuthManager()
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
if let button = statusItem.button {
button.image = NSImage(systemSymbolName: "music.note", accessibilityDescription: "Furumi")
button.action = #selector(handleClick)
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
button.target = self
}
popover = NSPopover()
popover.contentSize = NSSize(width: 320, height: 420)
popover.behavior = .transient
popover.contentViewController = NSHostingController(
rootView: ContentView().environment(authManager)
)
}
func application(_ application: NSApplication, open urls: [URL]) {
for url in urls where url.scheme == "furumi" {
Task {
await authManager.completeSSOLogin(callbackUrl: url.absoluteString)
}
}
}
// MARK: - Status item
@objc func handleClick() {
guard let button = statusItem.button else { return }
let event = NSApp.currentEvent
if event?.type == .rightMouseUp {
showContextMenu()
} else {
if popover.isShown {
popover.performClose(nil)
} else {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKey()
}
}
}
func showContextMenu() {
let menu = NSMenu()
let settingsItem = NSMenuItem(title: "Settings", action: #selector(openSettings), keyEquivalent: ",")
settingsItem.target = self
menu.addItem(settingsItem)
menu.addItem(.separator())
let quitItem = NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q")
quitItem.target = self
menu.addItem(quitItem)
statusItem.menu = menu
statusItem.button?.performClick(nil)
statusItem.menu = nil
}
@objc func openSettings() {
if let window = settingsWindow {
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 380),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.title = "Settings"
window.contentViewController = NSHostingController(
rootView: SettingsView().environment(authManager)
)
window.center()
window.isReleasedWhenClosed = false
settingsWindow = window
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
@objc func quit() {
NSApplication.shared.terminate(nil)
}
}