init
This commit is contained in:
@@ -394,6 +394,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = furumi_macos/furumi_macos.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -402,8 +403,8 @@
|
|||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_FILE = furumi_macos/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
@@ -426,6 +427,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = furumi_macos/furumi_macos.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -434,8 +436,8 @@
|
|||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_FILE = furumi_macos/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@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
|
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 {
|
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 {
|
var body: some View {
|
||||||
VStack {
|
Group {
|
||||||
Image(systemName: "globe")
|
if authManager.session == nil {
|
||||||
.imageScale(.large)
|
unauthorizedHelpView
|
||||||
.foregroundStyle(.tint)
|
} else {
|
||||||
Text("Hello, world!")
|
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 {
|
#Preview {
|
||||||
ContentView()
|
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_macosApp.swift
|
||||||
// furumi_macos
|
// furumi_macos
|
||||||
//
|
//
|
||||||
// Created by Alexandr Bogomiakov on 08/06/2026.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct furumi_macosApp: App {
|
struct furumi_macosApp: App {
|
||||||
|
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
Settings { EmptyView() }
|
||||||
ContentView()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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