From 2f8cff528c5f5a319f7248129511b95d4214926b Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Mon, 8 Jun 2026 16:50:16 +0100 Subject: [PATCH] init --- furumi_macos.xcodeproj/project.pbxproj | 10 +- furumi_macos/ArtistDetailViewModel.swift | 40 ++ furumi_macos/ArtistScreen.swift | 235 ++++++++ furumi_macos/AuthManager.swift | 208 +++++++ furumi_macos/AuthModels.swift | 98 ++++ furumi_macos/AuthService.swift | 107 ++++ furumi_macos/AuthSessionStorage.swift | 81 +++ furumi_macos/CatalogModels.swift | 387 +++++++++++++ furumi_macos/CatalogService.swift | 240 ++++++++ furumi_macos/ContentView.swift | 655 +++++++++++++++++++++- furumi_macos/DeviceManager.swift | 190 +++++++ furumi_macos/DeviceModels.swift | 178 ++++++ furumi_macos/DevicesView.swift | 98 ++++ furumi_macos/GlobalArtistsGridView.swift | 58 ++ furumi_macos/GlobalArtistsViewModel.swift | 69 +++ furumi_macos/ImageView.swift | 71 +++ furumi_macos/Info.plist | 37 ++ furumi_macos/PlayerManager.swift | 266 +++++++++ furumi_macos/PlaylistScreen.swift | 204 +++++++ furumi_macos/PlaylistsGridView.swift | 60 ++ furumi_macos/PlaylistsViewModel.swift | 37 ++ furumi_macos/QueueView.swift | 102 ++++ furumi_macos/ReleaseDetailViewModel.swift | 40 ++ furumi_macos/ReleaseScreen.swift | 202 +++++++ furumi_macos/SearchResultsView.swift | 169 ++++++ furumi_macos/SearchViewModel.swift | 64 +++ furumi_macos/SettingsView.swift | 144 +++++ furumi_macos/TrackInfoView.swift | 144 +++++ furumi_macos/furumi_macos.entitlements | 10 + furumi_macos/furumi_macosApp.swift | 107 +++- 30 files changed, 4296 insertions(+), 15 deletions(-) create mode 100644 furumi_macos/ArtistDetailViewModel.swift create mode 100644 furumi_macos/ArtistScreen.swift create mode 100644 furumi_macos/AuthManager.swift create mode 100644 furumi_macos/AuthModels.swift create mode 100644 furumi_macos/AuthService.swift create mode 100644 furumi_macos/AuthSessionStorage.swift create mode 100644 furumi_macos/CatalogModels.swift create mode 100644 furumi_macos/CatalogService.swift create mode 100644 furumi_macos/DeviceManager.swift create mode 100644 furumi_macos/DeviceModels.swift create mode 100644 furumi_macos/DevicesView.swift create mode 100644 furumi_macos/GlobalArtistsGridView.swift create mode 100644 furumi_macos/GlobalArtistsViewModel.swift create mode 100644 furumi_macos/ImageView.swift create mode 100644 furumi_macos/Info.plist create mode 100644 furumi_macos/PlayerManager.swift create mode 100644 furumi_macos/PlaylistScreen.swift create mode 100644 furumi_macos/PlaylistsGridView.swift create mode 100644 furumi_macos/PlaylistsViewModel.swift create mode 100644 furumi_macos/QueueView.swift create mode 100644 furumi_macos/ReleaseDetailViewModel.swift create mode 100644 furumi_macos/ReleaseScreen.swift create mode 100644 furumi_macos/SearchResultsView.swift create mode 100644 furumi_macos/SearchViewModel.swift create mode 100644 furumi_macos/SettingsView.swift create mode 100644 furumi_macos/TrackInfoView.swift create mode 100644 furumi_macos/furumi_macos.entitlements diff --git a/furumi_macos.xcodeproj/project.pbxproj b/furumi_macos.xcodeproj/project.pbxproj index 3e36cb2..34f1166 100644 --- a/furumi_macos.xcodeproj/project.pbxproj +++ b/furumi_macos.xcodeproj/project.pbxproj @@ -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", diff --git a/furumi_macos/ArtistDetailViewModel.swift b/furumi_macos/ArtistDetailViewModel.swift new file mode 100644 index 0000000..aded140 --- /dev/null +++ b/furumi_macos/ArtistDetailViewModel.swift @@ -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 + } + } +} diff --git a/furumi_macos/ArtistScreen.swift b/furumi_macos/ArtistScreen.swift new file mode 100644 index 0000000..ecd40fb --- /dev/null +++ b/furumi_macos/ArtistScreen.swift @@ -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) + } +} diff --git a/furumi_macos/AuthManager.swift b/furumi_macos/AuthManager.swift new file mode 100644 index 0000000..06f0428 --- /dev/null +++ b/furumi_macos/AuthManager.swift @@ -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 = [] + + 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 + } +} diff --git a/furumi_macos/AuthModels.swift b/furumi_macos/AuthModels.swift new file mode 100644 index 0000000..c0b2deb --- /dev/null +++ b/furumi_macos/AuthModels.swift @@ -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 + } + } +} diff --git a/furumi_macos/AuthService.swift b/furumi_macos/AuthService.swift new file mode 100644 index 0000000..32369eb --- /dev/null +++ b/furumi_macos/AuthService.swift @@ -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( + 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 + } + } +} diff --git a/furumi_macos/AuthSessionStorage.swift b/furumi_macos/AuthSessionStorage.swift new file mode 100644 index 0000000..ba02f94 --- /dev/null +++ b/furumi_macos/AuthSessionStorage.swift @@ -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) + } +} diff --git a/furumi_macos/CatalogModels.swift b/furumi_macos/CatalogModels.swift new file mode 100644 index 0000000..018f0df --- /dev/null +++ b/furumi_macos/CatalogModels.swift @@ -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) }) + } +} diff --git a/furumi_macos/CatalogService.swift b/furumi_macos/CatalogService.swift new file mode 100644 index 0000000..fd0424f --- /dev/null +++ b/furumi_macos/CatalogService.swift @@ -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(_ 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 { + 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 } diff --git a/furumi_macos/ContentView.swift b/furumi_macos/ContentView.swift index f7e28b9..77d4e40 100644 --- a/furumi_macos/ContentView.swift +++ b/furumi_macos/ContentView.swift @@ -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("You’re 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()) } diff --git a/furumi_macos/DeviceManager.swift b/furumi_macos/DeviceManager.swift new file mode 100644 index 0000000..84626ac --- /dev/null +++ b/furumi_macos/DeviceManager.swift @@ -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? + 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]]) + } +} diff --git a/furumi_macos/DeviceModels.swift b/furumi_macos/DeviceModels.swift new file mode 100644 index 0000000..d735c34 --- /dev/null +++ b/furumi_macos/DeviceModels.swift @@ -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" + } + } +} diff --git a/furumi_macos/DevicesView.swift b/furumi_macos/DevicesView.swift new file mode 100644 index 0000000..351be0f --- /dev/null +++ b/furumi_macos/DevicesView.swift @@ -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) + } +} diff --git a/furumi_macos/GlobalArtistsGridView.swift b/furumi_macos/GlobalArtistsGridView.swift new file mode 100644 index 0000000..36e21c1 --- /dev/null +++ b/furumi_macos/GlobalArtistsGridView.swift @@ -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))) + } +} diff --git a/furumi_macos/GlobalArtistsViewModel.swift b/furumi_macos/GlobalArtistsViewModel.swift new file mode 100644 index 0000000..7d785b6 --- /dev/null +++ b/furumi_macos/GlobalArtistsViewModel.swift @@ -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 + } + } +} diff --git a/furumi_macos/ImageView.swift b/furumi_macos/ImageView.swift new file mode 100644 index 0000000..ee4287c --- /dev/null +++ b/furumi_macos/ImageView.swift @@ -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) + } + } +} diff --git a/furumi_macos/Info.plist b/furumi_macos/Info.plist new file mode 100644 index 0000000..37b683b --- /dev/null +++ b/furumi_macos/Info.plist @@ -0,0 +1,37 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + LSUIElement + + CFBundleURLTypes + + + CFBundleURLSchemes + + furumi + + CFBundleURLName + cy.hexor.furumi + + + + diff --git a/furumi_macos/PlayerManager.swift b/furumi_macos/PlayerManager.swift new file mode 100644 index 0000000..6da2d17 --- /dev/null +++ b/furumi_macos/PlayerManager.swift @@ -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[.. 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) + } +} diff --git a/furumi_macos/PlaylistScreen.swift b/furumi_macos/PlaylistScreen.swift new file mode 100644 index 0000000..bc3e0dc --- /dev/null +++ b/furumi_macos/PlaylistScreen.swift @@ -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) + } +} diff --git a/furumi_macos/PlaylistsGridView.swift b/furumi_macos/PlaylistsGridView.swift new file mode 100644 index 0000000..efeeb4c --- /dev/null +++ b/furumi_macos/PlaylistsGridView.swift @@ -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))) + } +} diff --git a/furumi_macos/PlaylistsViewModel.swift b/furumi_macos/PlaylistsViewModel.swift new file mode 100644 index 0000000..ff3866a --- /dev/null +++ b/furumi_macos/PlaylistsViewModel.swift @@ -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 + } + } +} diff --git a/furumi_macos/QueueView.swift b/furumi_macos/QueueView.swift new file mode 100644 index 0000000..dae7b04 --- /dev/null +++ b/furumi_macos/QueueView.swift @@ -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) + } +} diff --git a/furumi_macos/ReleaseDetailViewModel.swift b/furumi_macos/ReleaseDetailViewModel.swift new file mode 100644 index 0000000..98a0332 --- /dev/null +++ b/furumi_macos/ReleaseDetailViewModel.swift @@ -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 + } + } +} diff --git a/furumi_macos/ReleaseScreen.swift b/furumi_macos/ReleaseScreen.swift new file mode 100644 index 0000000..31a770b --- /dev/null +++ b/furumi_macos/ReleaseScreen.swift @@ -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) + } +} diff --git a/furumi_macos/SearchResultsView.swift b/furumi_macos/SearchResultsView.swift new file mode 100644 index 0000000..36817f9 --- /dev/null +++ b/furumi_macos/SearchResultsView.swift @@ -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) + } +} diff --git a/furumi_macos/SearchViewModel.swift b/furumi_macos/SearchViewModel.swift new file mode 100644 index 0000000..342965b --- /dev/null +++ b/furumi_macos/SearchViewModel.swift @@ -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? + 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 + } + } +} diff --git a/furumi_macos/SettingsView.swift b/furumi_macos/SettingsView.swift new file mode 100644 index 0000000..c966ebb --- /dev/null +++ b/furumi_macos/SettingsView.swift @@ -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()) +} diff --git a/furumi_macos/TrackInfoView.swift b/furumi_macos/TrackInfoView.swift new file mode 100644 index 0000000..86d0050 --- /dev/null +++ b/furumi_macos/TrackInfoView.swift @@ -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)) + } +} diff --git a/furumi_macos/furumi_macos.entitlements b/furumi_macos/furumi_macos.entitlements new file mode 100644 index 0000000..1981043 --- /dev/null +++ b/furumi_macos/furumi_macos.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/furumi_macos/furumi_macosApp.swift b/furumi_macos/furumi_macosApp.swift index 5a59f48..cb1284d 100644 --- a/furumi_macos/furumi_macosApp.swift +++ b/furumi_macos/furumi_macosApp.swift @@ -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) } }