init
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//
|
||||
// AuthManager.swift
|
||||
// furumi_macos
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AuthManager {
|
||||
|
||||
var session: AuthSession?
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
var likedTrackIds: Set<Int64> = []
|
||||
|
||||
private let service = AuthService()
|
||||
private let storage = AuthSessionStorage()
|
||||
|
||||
init() {
|
||||
session = storage.load()
|
||||
}
|
||||
|
||||
// Catalog service bound to current session (closures keep token fresh)
|
||||
var catalogService: CatalogService? {
|
||||
guard let s = session else { return nil }
|
||||
return CatalogService(
|
||||
baseUrl: s.serverBaseUrl,
|
||||
authHeaderProvider: { [self] in
|
||||
await MainActor.run { self.session?.tokens.authorizationHeader ?? "" }
|
||||
},
|
||||
onUnauthorized: { [self] in
|
||||
await self.refreshTokens()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Liked tracks
|
||||
|
||||
func loadLikedIds() async {
|
||||
guard let service = catalogService else { return }
|
||||
guard let ids = try? await service.likedIds() else { return }
|
||||
likedTrackIds = ids
|
||||
}
|
||||
|
||||
func updateLikedState(trackId: Int64, liked: Bool) {
|
||||
if liked { likedTrackIds.insert(trackId) } else { likedTrackIds.remove(trackId) }
|
||||
}
|
||||
|
||||
// MARK: - Token refresh
|
||||
|
||||
func refreshTokens() async -> Bool {
|
||||
guard let current = session else { return false }
|
||||
do {
|
||||
let tokenResponse = try await service.refresh(
|
||||
baseUrl: current.serverBaseUrl,
|
||||
refreshToken: current.tokens.refreshToken
|
||||
)
|
||||
let now = Int64(Date().timeIntervalSince1970)
|
||||
let expiresAt = tokenResponse.expiresInSeconds > 0
|
||||
? now + Int64(tokenResponse.expiresInSeconds) : 0
|
||||
let newSession = AuthSession(
|
||||
serverBaseUrl: current.serverBaseUrl,
|
||||
user: current.user,
|
||||
tokens: AuthTokens(
|
||||
accessToken: tokenResponse.accessToken,
|
||||
refreshToken: tokenResponse.refreshToken,
|
||||
tokenType: tokenResponse.tokenType,
|
||||
expiresInSeconds: tokenResponse.expiresInSeconds,
|
||||
expiresAtEpochSeconds: expiresAt
|
||||
)
|
||||
)
|
||||
storage.save(newSession)
|
||||
session = newSession
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Password login
|
||||
|
||||
func login(serverUrl: String, username: String, password: String) async {
|
||||
guard let baseUrl = normalizeURL(serverUrl) else {
|
||||
error = "Invalid server URL"
|
||||
return
|
||||
}
|
||||
guard !username.trimmingCharacters(in: .whitespaces).isEmpty else {
|
||||
error = "Username is required"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
error = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let response = try await service.login(baseUrl: baseUrl, username: username, password: password)
|
||||
let authSession = response.toAuthSession(serverBaseUrl: baseUrl)
|
||||
storage.save(authSession)
|
||||
session = authSession
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SSO
|
||||
|
||||
func startSSO(serverUrl: String) {
|
||||
guard let baseUrl = normalizeURL(serverUrl) else {
|
||||
error = "Invalid server URL"
|
||||
return
|
||||
}
|
||||
storage.savePendingSsoBaseUrl(baseUrl)
|
||||
guard let url = service.ssoStartURL(baseUrl: baseUrl) else {
|
||||
error = "Failed to build SSO URL"
|
||||
return
|
||||
}
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
func completeSSOLogin(callbackUrl: String) async {
|
||||
guard let url = URL(string: callbackUrl),
|
||||
url.scheme == "furumi",
|
||||
url.host == "auth",
|
||||
url.path == "/callback" else {
|
||||
return
|
||||
}
|
||||
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
|
||||
if let ssoError = components?.queryItems?.first(where: { $0.name == "error" })?.value {
|
||||
error = "SSO error: \(ssoError)"
|
||||
return
|
||||
}
|
||||
|
||||
guard let code = components?.queryItems?.first(where: { $0.name == "code" })?.value,
|
||||
!code.isEmpty else {
|
||||
error = "SSO callback does not contain an authorization code"
|
||||
return
|
||||
}
|
||||
|
||||
guard let baseUrl = storage.getPendingSsoBaseUrl() ?? session?.serverBaseUrl else {
|
||||
error = "SSO server is missing. Start SSO login again."
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
error = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let response = try await service.ssoExchange(baseUrl: baseUrl, code: code)
|
||||
let authSession = response.toAuthSession(serverBaseUrl: baseUrl)
|
||||
storage.save(authSession)
|
||||
session = authSession
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Logout
|
||||
|
||||
func logout() async {
|
||||
guard let currentSession = session else { return }
|
||||
|
||||
isLoading = true
|
||||
defer {
|
||||
isLoading = false
|
||||
storage.clear()
|
||||
session = nil
|
||||
}
|
||||
|
||||
try? await service.logout(
|
||||
baseUrl: currentSession.serverBaseUrl,
|
||||
authorizationHeader: currentSession.tokens.authorizationHeader,
|
||||
refreshToken: currentSession.tokens.refreshToken
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - URL normalization (ported from Android ServerConfig)
|
||||
|
||||
func normalizeURL(_ raw: String) -> String? {
|
||||
var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
while trimmed.hasSuffix("/") { trimmed = String(trimmed.dropLast()) }
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
if !trimmed.lowercased().hasPrefix("http://") && !trimmed.lowercased().hasPrefix("https://") {
|
||||
trimmed = "https://\(trimmed)"
|
||||
}
|
||||
|
||||
guard let url = URL(string: trimmed),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
(scheme == "http" || scheme == "https"),
|
||||
let host = url.host, !host.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var result = "\(scheme)://\(host.lowercased())"
|
||||
if let port = url.port { result += ":\(port)" }
|
||||
|
||||
let path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
if !path.isEmpty { result += "/\(path)" }
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// AuthService.swift
|
||||
// furumi_macos
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct AuthService {
|
||||
|
||||
private let urlSession = URLSession.shared
|
||||
|
||||
private let decoder: JSONDecoder = {
|
||||
let d = JSONDecoder()
|
||||
d.keyDecodingStrategy = .convertFromSnakeCase
|
||||
return d
|
||||
}()
|
||||
|
||||
private var deviceName: String {
|
||||
ProcessInfo.processInfo.hostName.components(separatedBy: ".").first ?? "macOS"
|
||||
}
|
||||
|
||||
// MARK: - URL builders
|
||||
|
||||
func ssoStartURL(baseUrl: String) -> URL? {
|
||||
guard var components = URLComponents(string: "\(baseUrl)/auth/mobile/oidc/start") else { return nil }
|
||||
components.queryItems = [URLQueryItem(name: "redirect_uri", value: "furumi://auth/callback")]
|
||||
return components.url
|
||||
}
|
||||
|
||||
// MARK: - API
|
||||
|
||||
func login(baseUrl: String, username: String, password: String) async throws -> LoginResponse {
|
||||
struct Body: Encodable {
|
||||
let username: String
|
||||
let password: String
|
||||
let device_name: String
|
||||
}
|
||||
return try await post(
|
||||
urlString: "\(baseUrl)/api/auth/password",
|
||||
body: Body(username: username, password: password, device_name: deviceName)
|
||||
)
|
||||
}
|
||||
|
||||
func ssoExchange(baseUrl: String, code: String) async throws -> LoginResponse {
|
||||
struct Body: Encodable {
|
||||
let code: String
|
||||
let device_name: String
|
||||
}
|
||||
return try await post(
|
||||
urlString: "\(baseUrl)/api/auth/sso/exchange",
|
||||
body: Body(code: code, device_name: deviceName)
|
||||
)
|
||||
}
|
||||
|
||||
func refresh(baseUrl: String, refreshToken: String) async throws -> TokenResponse {
|
||||
struct Body: Encodable { let refresh_token: String }
|
||||
return try await post(
|
||||
urlString: "\(baseUrl)/api/auth/refresh",
|
||||
body: Body(refresh_token: refreshToken)
|
||||
)
|
||||
}
|
||||
|
||||
func logout(baseUrl: String, authorizationHeader: String, refreshToken: String) async throws {
|
||||
struct Body: Encodable { let refresh_token: String }
|
||||
let _: LogoutResponse = try await post(
|
||||
urlString: "\(baseUrl)/api/auth/logout",
|
||||
body: Body(refresh_token: refreshToken),
|
||||
authHeader: authorizationHeader
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func post<B: Encodable, R: Decodable>(
|
||||
urlString: String,
|
||||
body: B,
|
||||
authHeader: String? = nil
|
||||
) async throws -> R {
|
||||
guard let url = URL(string: urlString) else {
|
||||
throw AuthError.invalidServerUrl("Invalid URL: \(urlString)")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let authHeader { request.setValue(authHeader, forHTTPHeaderField: "Authorization") }
|
||||
request.httpBody = try JSONEncoder().encode(body)
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw AuthError.invalidResponse
|
||||
}
|
||||
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let msg = (try? decoder.decode(ErrorResponse.self, from: data))?.error ?? ""
|
||||
throw AuthError.serverError(http.statusCode, msg)
|
||||
}
|
||||
|
||||
do {
|
||||
return try decoder.decode(R.self, from: data)
|
||||
} catch {
|
||||
throw AuthError.invalidResponse
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//
|
||||
// CatalogService.swift
|
||||
// furumi_macos
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum CatalogError: LocalizedError {
|
||||
case httpError(statusCode: Int, message: String)
|
||||
case decodingError(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .httpError(let code, let message):
|
||||
return message.isEmpty ? "Server error \(code)" : "Server error \(code): \(message)"
|
||||
case .decodingError(let detail):
|
||||
return "Response parsing failed: \(detail)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CatalogService {
|
||||
let baseUrl: String
|
||||
let authHeaderProvider: () async -> String
|
||||
let onUnauthorized: () async -> Bool
|
||||
|
||||
private let decoder: JSONDecoder = {
|
||||
let d = JSONDecoder()
|
||||
d.keyDecodingStrategy = .convertFromSnakeCase
|
||||
return d
|
||||
}()
|
||||
|
||||
private func makeRequest(path: String, query: [URLQueryItem] = []) async throws -> URLRequest {
|
||||
guard var components = URLComponents(string: baseUrl) else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
components.path = "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
if !query.isEmpty {
|
||||
components.queryItems = query
|
||||
}
|
||||
guard let url = components.url else { throw URLError(.badURL) }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "GET"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
req.setValue(await authHeaderProvider(), forHTTPHeaderField: "Authorization")
|
||||
return req
|
||||
}
|
||||
|
||||
private func checkResponse(_ response: URLResponse, data: Data) throws {
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let message = (try? decoder.decode(ErrorResponse.self, from: data))?.error
|
||||
?? (String(data: data, encoding: .utf8).map { $0.prefix(200) }.map(String.init) ?? "")
|
||||
throw CatalogError.httpError(statusCode: http.statusCode, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
private func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
|
||||
do {
|
||||
return try decoder.decode(type, from: data)
|
||||
} catch let e as DecodingError {
|
||||
throw CatalogError.decodingError(e.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetches data with a single 401-triggered refresh+retry
|
||||
private func post(path: String, body: Data) async throws -> Data {
|
||||
guard var components = URLComponents(string: baseUrl) else { throw URLError(.badURL) }
|
||||
components.path = "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let url = components.url else { throw URLError(.badURL) }
|
||||
|
||||
func makeReq() async -> URLRequest {
|
||||
var r = URLRequest(url: url)
|
||||
r.httpMethod = "POST"
|
||||
r.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
r.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
r.httpBody = body
|
||||
return r
|
||||
}
|
||||
|
||||
var req = await makeReq()
|
||||
req.setValue(await authHeaderProvider(), forHTTPHeaderField: "Authorization")
|
||||
let (data, response) = try await URLSession.shared.data(for: req)
|
||||
|
||||
if let http = response as? HTTPURLResponse, http.statusCode == 401 {
|
||||
guard await onUnauthorized() else {
|
||||
throw CatalogError.httpError(statusCode: 401, message: "Unauthorized")
|
||||
}
|
||||
var retry = await makeReq()
|
||||
retry.setValue(await authHeaderProvider(), forHTTPHeaderField: "Authorization")
|
||||
let (data2, response2) = try await URLSession.shared.data(for: retry)
|
||||
try checkResponse(response2, data: data2)
|
||||
return data2
|
||||
}
|
||||
try checkResponse(response, data: data)
|
||||
return data
|
||||
}
|
||||
|
||||
private func fetch(path: String, query: [URLQueryItem] = []) async throws -> Data {
|
||||
let req = try await makeRequest(path: path, query: query)
|
||||
let (data, response) = try await URLSession.shared.data(for: req)
|
||||
|
||||
if let http = response as? HTTPURLResponse, http.statusCode == 401 {
|
||||
guard await onUnauthorized() else {
|
||||
throw CatalogError.httpError(statusCode: 401, message: "Unauthorized")
|
||||
}
|
||||
let retryReq = try await makeRequest(path: path, query: query)
|
||||
let (data2, response2) = try await URLSession.shared.data(for: retryReq)
|
||||
try checkResponse(response2, data: data2)
|
||||
return data2
|
||||
}
|
||||
|
||||
try checkResponse(response, data: data)
|
||||
return data
|
||||
}
|
||||
|
||||
func artists(page: Int, limit: Int, mine: Bool) async throws -> ArtistPage {
|
||||
let data = try await fetch(
|
||||
path: "api/player/artists",
|
||||
query: [
|
||||
URLQueryItem(name: "page", value: String(page)),
|
||||
URLQueryItem(name: "limit", value: String(limit)),
|
||||
URLQueryItem(name: "mine", value: mine ? "true" : "false")
|
||||
]
|
||||
)
|
||||
return try decode(ArtistPageDTO.self, from: data).toDomain(baseUrl: baseUrl)
|
||||
}
|
||||
|
||||
func artistDetail(id: Int64) async throws -> ArtistDetail {
|
||||
let data = try await fetch(path: "api/player/artists/\(id)")
|
||||
return try decode(ArtistDetailDTO.self, from: data).toDomain(baseUrl: baseUrl)
|
||||
}
|
||||
|
||||
func releaseDetail(id: Int64) async throws -> ReleaseDetail {
|
||||
let data = try await fetch(path: "api/player/releases/\(id)")
|
||||
return try decode(ReleaseDetailDTO.self, from: data).toDomain(baseUrl: baseUrl)
|
||||
}
|
||||
|
||||
func search(query: String, limit: Int) async throws -> SearchResult {
|
||||
let data = try await fetch(
|
||||
path: "api/player/search",
|
||||
query: [
|
||||
URLQueryItem(name: "q", value: query),
|
||||
URLQueryItem(name: "limit", value: String(limit))
|
||||
]
|
||||
)
|
||||
return try decode(SearchResponseDTO.self, from: data).toDomain(baseUrl: baseUrl)
|
||||
}
|
||||
|
||||
// MARK: - Device Hub
|
||||
|
||||
private static let deviceEncoder: JSONEncoder = {
|
||||
let e = JSONEncoder()
|
||||
e.keyEncodingStrategy = .convertToSnakeCase
|
||||
return e
|
||||
}()
|
||||
|
||||
func devicePoll(deviceId: String, userAgent: String, playbackState: DevicePlaybackState?) async throws -> Data {
|
||||
struct Body: Encodable {
|
||||
let deviceId: String
|
||||
let userAgent: String?
|
||||
let currentJamId: String?
|
||||
let playbackState: DevicePlaybackState?
|
||||
}
|
||||
let body = try Self.deviceEncoder.encode(Body(deviceId: deviceId, userAgent: userAgent, currentJamId: nil, playbackState: playbackState))
|
||||
return try await post(path: "api/player/devices/poll", body: body)
|
||||
}
|
||||
|
||||
func deviceSelect(currentDeviceId: String, targetDeviceId: String) async throws -> Data {
|
||||
struct Body: Encodable {
|
||||
let deviceId: String
|
||||
let currentDeviceId: String?
|
||||
}
|
||||
let body = try Self.deviceEncoder.encode(Body(deviceId: targetDeviceId, currentDeviceId: currentDeviceId))
|
||||
return try await post(path: "api/player/devices/active", body: body)
|
||||
}
|
||||
|
||||
func deviceCommand(command: String, targetDeviceId: String?, payloadData: Data) async throws {
|
||||
// Build JSON manually to embed arbitrary payload
|
||||
var obj: [String: Any] = ["command": command]
|
||||
if let tid = targetDeviceId { obj["target_device_id"] = tid }
|
||||
if let payloadObj = try? JSONSerialization.jsonObject(with: payloadData) {
|
||||
obj["payload"] = payloadObj
|
||||
} else {
|
||||
obj["payload"] = [String: Any]()
|
||||
}
|
||||
let body = try JSONSerialization.data(withJSONObject: obj)
|
||||
_ = try await post(path: "api/player/devices/command", body: body)
|
||||
}
|
||||
|
||||
func playlists() async throws -> [PlaylistCard] {
|
||||
let data = try await fetch(path: "api/player/playlists")
|
||||
return try decode([PlaylistCardDTO].self, from: data).map { $0.toDomain() }
|
||||
}
|
||||
|
||||
func playlistDetail(id: Int64) async throws -> PlaylistDetail {
|
||||
let data = try await fetch(path: "api/player/playlists/\(id)")
|
||||
return try decode(PlaylistDetailDTO.self, from: data).toDomain(baseUrl: baseUrl)
|
||||
}
|
||||
|
||||
func likedIds() async throws -> Set<Int64> {
|
||||
let data = try await fetch(path: "api/player/likes")
|
||||
let dto = try decode(LikedIdsDTO.self, from: data)
|
||||
return Set(dto.trackIds)
|
||||
}
|
||||
|
||||
// Returns new liked state after toggle
|
||||
func toggleLike(trackId: Int64) async throws -> Bool {
|
||||
guard var components = URLComponents(string: baseUrl) else { throw URLError(.badURL) }
|
||||
components.path = "/api/player/likes/toggle/\(trackId)"
|
||||
guard let url = components.url else { throw URLError(.badURL) }
|
||||
|
||||
func makeReq() async -> URLRequest {
|
||||
var r = URLRequest(url: url)
|
||||
r.httpMethod = "POST"
|
||||
r.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
r.setValue(await authHeaderProvider(), forHTTPHeaderField: "Authorization")
|
||||
return r
|
||||
}
|
||||
|
||||
let req = await makeReq()
|
||||
let (data, response) = try await URLSession.shared.data(for: req)
|
||||
|
||||
if let http = response as? HTTPURLResponse, http.statusCode == 401 {
|
||||
guard await onUnauthorized() else {
|
||||
throw CatalogError.httpError(statusCode: 401, message: "Unauthorized")
|
||||
}
|
||||
let retryReq = await makeReq()
|
||||
let (data2, response2) = try await URLSession.shared.data(for: retryReq)
|
||||
try checkResponse(response2, data: data2)
|
||||
return try decode(LikeStatusDTO.self, from: data2).liked
|
||||
}
|
||||
try checkResponse(response, data: data)
|
||||
return try decode(LikeStatusDTO.self, from: data).liked
|
||||
}
|
||||
}
|
||||
|
||||
private struct LikeStatusDTO: Decodable { let liked: Bool }
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//
|
||||
// DeviceManager.swift
|
||||
// furumi_macos
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class DeviceManager {
|
||||
|
||||
// User-Agent sent in every request — product name recognized by server
|
||||
static let userAgent: String = {
|
||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
|
||||
return "FurumiMacOS/\(version) macOS"
|
||||
}()
|
||||
|
||||
// Persistent device ID (alphanumeric + hyphens, accepted by server's normalize_device_id)
|
||||
let deviceId: String
|
||||
|
||||
private(set) var devices: [PlayerDeviceInfo] = []
|
||||
private(set) var activeDeviceId: String?
|
||||
private(set) var remoteState: DevicePlaybackState?
|
||||
private(set) var pendingTransferState: DevicePlaybackState?
|
||||
private(set) var isConnected = false
|
||||
|
||||
/// true when this Mac is the active (playing) device or no active device is set
|
||||
var isThisDeviceActive: Bool {
|
||||
activeDeviceId == nil || activeDeviceId == deviceId
|
||||
}
|
||||
|
||||
private var service: CatalogService?
|
||||
private var pollTask: Task<Void, Never>?
|
||||
private let decoder: JSONDecoder = {
|
||||
let d = JSONDecoder()
|
||||
d.keyDecodingStrategy = .convertFromSnakeCase
|
||||
return d
|
||||
}()
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init() {
|
||||
if let saved = UserDefaults.standard.string(forKey: "device.id") {
|
||||
deviceId = saved
|
||||
} else {
|
||||
let newId = UUID().uuidString // hyphens ok per normalize_device_id
|
||||
UserDefaults.standard.set(newId, forKey: "device.id")
|
||||
deviceId = newId
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func start(service: CatalogService, player: PlayerManager) {
|
||||
self.service = service
|
||||
stopPolling()
|
||||
pollTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
await self?.doPoll(player: player)
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stopPolling()
|
||||
service = nil
|
||||
devices = []
|
||||
activeDeviceId = nil
|
||||
remoteState = nil
|
||||
pendingTransferState = nil
|
||||
isConnected = false
|
||||
}
|
||||
|
||||
private func stopPolling() {
|
||||
pollTask?.cancel()
|
||||
pollTask = nil
|
||||
}
|
||||
|
||||
// MARK: - Polling
|
||||
|
||||
private func doPoll(player: PlayerManager) async {
|
||||
guard let service else { return }
|
||||
let state = isThisDeviceActive ? DevicePlaybackState.from(player) : nil
|
||||
do {
|
||||
let data = try await service.devicePoll(
|
||||
deviceId: deviceId,
|
||||
userAgent: Self.userAgent,
|
||||
playbackState: state
|
||||
)
|
||||
let response = try decoder.decode(DevicePollResponseDTO.self, from: data)
|
||||
applyPollResponse(response, player: player)
|
||||
} catch { }
|
||||
}
|
||||
|
||||
private func applyPollResponse(_ response: DevicePollResponseDTO, player: PlayerManager) {
|
||||
isConnected = true
|
||||
devices = response.devices.map { $0.toDomain() }
|
||||
activeDeviceId = response.activeDeviceId
|
||||
|
||||
if isThisDeviceActive {
|
||||
remoteState = nil
|
||||
} else {
|
||||
remoteState = response.playbackState
|
||||
}
|
||||
|
||||
for cmd in response.commands {
|
||||
executeCommand(cmd, player: player)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Command execution
|
||||
|
||||
private func executeCommand(_ cmd: PlayerDeviceCommandDTO, player: PlayerManager) {
|
||||
let p = cmd.payload
|
||||
switch cmd.command {
|
||||
case "transfer_state":
|
||||
// Server set us as active and sent state to restore
|
||||
pendingTransferState = DevicePlaybackState(
|
||||
track: p.track,
|
||||
tracks: p.tracks ?? [],
|
||||
index: p.index ?? 0,
|
||||
positionSeconds: p.positionSeconds ?? 0,
|
||||
durationSeconds: p.durationSeconds ?? 0,
|
||||
paused: p.paused ?? false,
|
||||
shuffle: p.shuffle ?? false,
|
||||
repeatMode: p.repeatMode ?? "none",
|
||||
volume: p.volume ?? Double(player.volume),
|
||||
updatedAtMs: p.updatedAtMs ?? 0
|
||||
)
|
||||
case "play":
|
||||
if !player.isPlaying { player.togglePlayPause() }
|
||||
case "pause":
|
||||
if player.isPlaying { player.togglePlayPause() }
|
||||
case "next":
|
||||
if player.canGoNext { player.playNext() }
|
||||
case "previous":
|
||||
if player.canGoPrevious { player.playPrevious() }
|
||||
case "seek":
|
||||
if let pos = p.positionSeconds, player.duration > 0 {
|
||||
player.seek(to: pos / player.duration)
|
||||
}
|
||||
case "volume":
|
||||
if let vol = p.volume {
|
||||
player.volume = Float(vol)
|
||||
}
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
func clearPendingTransfer() {
|
||||
pendingTransferState = nil
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func selectDevice(_ targetId: String) async {
|
||||
guard let service else { return }
|
||||
do {
|
||||
let data = try await service.deviceSelect(currentDeviceId: deviceId, targetDeviceId: targetId)
|
||||
let response = try decoder.decode(DevicesResponseDTO.self, from: data)
|
||||
devices = response.devices.map { $0.toDomain() }
|
||||
activeDeviceId = response.activeDeviceId
|
||||
if isThisDeviceActive { remoteState = nil }
|
||||
} catch { }
|
||||
}
|
||||
|
||||
func sendCommand(_ command: String, targetDeviceId: String? = nil, payload: [String: Any] = [:]) async {
|
||||
guard let service else { return }
|
||||
let payloadData = (try? JSONSerialization.data(withJSONObject: payload)) ?? Data("{}".utf8)
|
||||
try? await service.deviceCommand(command: command, targetDeviceId: targetDeviceId, payloadData: payloadData)
|
||||
}
|
||||
|
||||
func sendTrackCommand(_ command: String, track: TrackCard) async {
|
||||
let trackDict: [String: Any] = [
|
||||
"id": track.id,
|
||||
"title": track.title,
|
||||
"duration_seconds": Double(track.durationSeconds),
|
||||
"stream_url": track.streamUrl,
|
||||
"cover_url": track.coverUrl as Any,
|
||||
"release_id": track.releaseId,
|
||||
"release_title": track.releaseTitle,
|
||||
"artists": track.artistNames.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.map { ["id": 0, "name": $0] as [String: Any] }
|
||||
]
|
||||
await sendCommand(command, payload: ["tracks": [trackDict]])
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>furumi</string>
|
||||
</array>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>cy.hexor.furumi</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,266 @@
|
||||
//
|
||||
// PlayerManager.swift
|
||||
// furumi_macos
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import AVFoundation
|
||||
import MediaPlayer
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PlayerManager {
|
||||
private(set) var currentTrack: TrackCard?
|
||||
private(set) var isPlaying = false
|
||||
private(set) var currentTime: Double = 0
|
||||
private(set) var duration: Double = 0
|
||||
private(set) var queue: [TrackCard] = []
|
||||
private(set) var history: [TrackCard] = []
|
||||
|
||||
var volume: Float = {
|
||||
let v = UserDefaults.standard.float(forKey: "player.volume")
|
||||
return v > 0 ? v : 1.0
|
||||
}() {
|
||||
didSet {
|
||||
player?.volume = volume
|
||||
UserDefaults.standard.set(volume, forKey: "player.volume")
|
||||
}
|
||||
}
|
||||
|
||||
var progress: Double {
|
||||
duration > 0 ? min(currentTime / duration, 1) : 0
|
||||
}
|
||||
var canGoNext: Bool { !queue.isEmpty }
|
||||
var canGoPrevious: Bool { !history.isEmpty }
|
||||
var formattedCurrentTime: String { formatTime(currentTime) }
|
||||
|
||||
private var player: AVPlayer?
|
||||
private var timeObserver: Any?
|
||||
private var lastAuthHeader = ""
|
||||
|
||||
init() {
|
||||
setupRemoteCommands()
|
||||
}
|
||||
|
||||
// MARK: - Playback
|
||||
|
||||
// Core playback — does not modify history or queue
|
||||
private func playCore(track: TrackCard, authHeader: String) {
|
||||
guard let url = URL(string: track.streamUrl) else { return }
|
||||
lastAuthHeader = authHeader
|
||||
clearObserver()
|
||||
|
||||
let asset = AVURLAsset(url: url, options: [
|
||||
"AVURLAssetHTTPHeaderFieldsKey": ["Authorization": authHeader]
|
||||
])
|
||||
let item = AVPlayerItem(asset: asset)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: AVPlayerItem.didPlayToEndTimeNotification,
|
||||
object: item,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
if !self.queue.isEmpty {
|
||||
self.playNext()
|
||||
} else {
|
||||
self.isPlaying = false
|
||||
self.updateNowPlayingPlaybackState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let player {
|
||||
player.replaceCurrentItem(with: item)
|
||||
} else {
|
||||
player = AVPlayer(playerItem: item)
|
||||
}
|
||||
player?.volume = volume
|
||||
player?.play()
|
||||
isPlaying = true
|
||||
currentTrack = track
|
||||
currentTime = 0
|
||||
duration = Double(track.durationSeconds)
|
||||
|
||||
addObserver()
|
||||
updateNowPlayingInfo(track: track)
|
||||
}
|
||||
|
||||
func play(track: TrackCard, authHeader: String) {
|
||||
if let current = currentTrack {
|
||||
history.append(current)
|
||||
if history.count > 50 { history.removeFirst() }
|
||||
}
|
||||
playCore(track: track, authHeader: authHeader)
|
||||
}
|
||||
|
||||
func togglePlayPause() {
|
||||
guard let player else { return }
|
||||
if isPlaying {
|
||||
player.pause()
|
||||
isPlaying = false
|
||||
} else {
|
||||
player.play()
|
||||
isPlaying = true
|
||||
}
|
||||
updateNowPlayingPlaybackState()
|
||||
}
|
||||
|
||||
func playNext() {
|
||||
guard !queue.isEmpty else { return }
|
||||
if let current = currentTrack {
|
||||
history.append(current)
|
||||
if history.count > 50 { history.removeFirst() }
|
||||
}
|
||||
let next = queue.removeFirst()
|
||||
playCore(track: next, authHeader: lastAuthHeader)
|
||||
}
|
||||
|
||||
func playPrevious() {
|
||||
guard !history.isEmpty else { return }
|
||||
if let current = currentTrack { queue.insert(current, at: 0) }
|
||||
let prev = history.removeLast()
|
||||
playCore(track: prev, authHeader: lastAuthHeader)
|
||||
}
|
||||
|
||||
func playFromHistory(at index: Int) {
|
||||
guard index < history.count else { return }
|
||||
let track = history[index]
|
||||
var restored = Array(history[(index + 1)...])
|
||||
if let current = currentTrack { restored.append(current) }
|
||||
restored.append(contentsOf: queue)
|
||||
history = Array(history[..<index])
|
||||
queue = restored
|
||||
playCore(track: track, authHeader: lastAuthHeader)
|
||||
}
|
||||
|
||||
func setQueue(_ tracks: [TrackCard]) {
|
||||
queue = tracks
|
||||
}
|
||||
|
||||
func addToQueue(_ track: TrackCard) {
|
||||
queue.append(track)
|
||||
}
|
||||
|
||||
func addToQueueNext(_ track: TrackCard) {
|
||||
queue.insert(track, at: 0)
|
||||
}
|
||||
|
||||
func removeFromQueue(at offsets: IndexSet) {
|
||||
queue.remove(atOffsets: offsets)
|
||||
}
|
||||
|
||||
func seek(to fraction: Double) {
|
||||
guard let player, duration > 0 else { return }
|
||||
let seconds = fraction * duration
|
||||
player.seek(to: CMTime(seconds: seconds, preferredTimescale: 600))
|
||||
currentTime = seconds
|
||||
updateNowPlayingPlaybackState()
|
||||
}
|
||||
|
||||
// MARK: - Media keys (MPRemoteCommandCenter)
|
||||
|
||||
private func setupRemoteCommands() {
|
||||
let center = MPRemoteCommandCenter.shared()
|
||||
|
||||
center.playCommand.addTarget { [weak self] _ in
|
||||
guard let self, !self.isPlaying else { return .commandFailed }
|
||||
self.togglePlayPause()
|
||||
return .success
|
||||
}
|
||||
center.pauseCommand.addTarget { [weak self] _ in
|
||||
guard let self, self.isPlaying else { return .commandFailed }
|
||||
self.togglePlayPause()
|
||||
return .success
|
||||
}
|
||||
center.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||
guard let self else { return .commandFailed }
|
||||
self.togglePlayPause()
|
||||
return .success
|
||||
}
|
||||
center.nextTrackCommand.isEnabled = true
|
||||
center.nextTrackCommand.addTarget { [weak self] _ in
|
||||
guard let self, self.canGoNext else { return .commandFailed }
|
||||
self.playNext()
|
||||
return .success
|
||||
}
|
||||
center.previousTrackCommand.addTarget { [weak self] _ in
|
||||
guard let self else { return .commandFailed }
|
||||
if self.canGoPrevious {
|
||||
self.playPrevious()
|
||||
} else {
|
||||
self.seek(to: 0)
|
||||
}
|
||||
return .success
|
||||
}
|
||||
center.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||
guard let self,
|
||||
let e = event as? MPChangePlaybackPositionCommandEvent,
|
||||
self.duration > 0 else { return .commandFailed }
|
||||
self.seek(to: e.positionTime / self.duration)
|
||||
return .success
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Now Playing info
|
||||
|
||||
private func updateNowPlayingInfo(track: TrackCard) {
|
||||
var info: [String: Any] = [
|
||||
MPMediaItemPropertyTitle: track.title,
|
||||
MPMediaItemPropertyArtist: track.artistNames,
|
||||
MPMediaItemPropertyPlaybackDuration: Double(track.durationSeconds),
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: 0,
|
||||
MPNowPlayingInfoPropertyPlaybackRate: 1.0
|
||||
]
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||
|
||||
// Load cover art asynchronously
|
||||
if let urlString = track.coverUrl, let url = URL(string: urlString) {
|
||||
Task {
|
||||
guard let (data, _) = try? await URLSession.shared.data(from: url),
|
||||
let nsImage = NSImage(data: data) else { return }
|
||||
let artwork = MPMediaItemArtwork(boundsSize: CGSize(width: 300, height: 300)) { _ in nsImage }
|
||||
info[MPMediaItemPropertyArtwork] = artwork
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateNowPlayingPlaybackState() {
|
||||
guard var info = MPNowPlayingInfoCenter.default().nowPlayingInfo else { return }
|
||||
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
||||
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
private func addObserver() {
|
||||
guard let player else { return }
|
||||
let interval = CMTime(seconds: 0.25, preferredTimescale: 600)
|
||||
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||||
guard let self else { return }
|
||||
self.currentTime = time.seconds
|
||||
if let d = self.player?.currentItem?.duration, d.isNumeric, d.seconds > 0 {
|
||||
self.duration = d.seconds
|
||||
}
|
||||
self.isPlaying = (self.player?.rate ?? 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
private func clearObserver() {
|
||||
if let observer = timeObserver, let player {
|
||||
player.removeTimeObserver(observer)
|
||||
}
|
||||
timeObserver = nil
|
||||
}
|
||||
|
||||
private func formatTime(_ seconds: Double) -> String {
|
||||
guard seconds.isFinite, seconds >= 0 else { return "--:--" }
|
||||
let s = Int(seconds)
|
||||
return String(format: "%d:%02d", s / 60, s % 60)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// SearchViewModel.swift
|
||||
// furumi_macos
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SearchViewModel {
|
||||
private var service: CatalogService
|
||||
private(set) var results: SearchResult?
|
||||
private(set) var isSearching = false
|
||||
private(set) var error: String?
|
||||
|
||||
var query: String = "" {
|
||||
didSet { debounceSearch() }
|
||||
}
|
||||
|
||||
private var pendingTask: Task<Void, Never>?
|
||||
private let limit = 20
|
||||
|
||||
init(service: CatalogService) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
func reset(service: CatalogService) {
|
||||
self.service = service
|
||||
self.results = nil
|
||||
self.isSearching = false
|
||||
self.error = nil
|
||||
self.query = ""
|
||||
pendingTask?.cancel()
|
||||
pendingTask = nil
|
||||
}
|
||||
|
||||
private func debounceSearch() {
|
||||
pendingTask?.cancel()
|
||||
let q = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !q.isEmpty else { results = nil; error = nil; return }
|
||||
pendingTask = Task { [weak self] in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 300_000_000) // 300ms
|
||||
} catch {
|
||||
return // cancelled — не вызываем поиск
|
||||
}
|
||||
await self?.performSearch(q)
|
||||
}
|
||||
}
|
||||
|
||||
private func performSearch(_ q: String) async {
|
||||
guard !q.isEmpty else { return }
|
||||
isSearching = true
|
||||
error = nil
|
||||
defer { isSearching = false }
|
||||
do {
|
||||
results = try await service.search(query: q, limit: limit)
|
||||
} catch {
|
||||
self.error = (error as NSError).localizedDescription
|
||||
self.results = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -2,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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user