Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80aa04f625 | |||
| 88297cbc91 | |||
| ab352b9595 | |||
| 37a6ccaf0c |
@@ -409,7 +409,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2;
|
||||
MARKETING_VERSION = 1.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "hexor.furumi-macos";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -442,7 +442,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2;
|
||||
MARKETING_VERSION = 1.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "hexor.furumi-macos";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// AppLogger.swift
|
||||
// furumi_macos
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppLogger {
|
||||
static let shared = AppLogger()
|
||||
|
||||
private(set) var lines: [String] = []
|
||||
private let maxLines = 500
|
||||
|
||||
private init() {}
|
||||
|
||||
func log(_ message: String) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss.SSS"
|
||||
let timestamp = formatter.string(from: Date())
|
||||
lines.append("[\(timestamp)] \(message)")
|
||||
if lines.count > maxLines {
|
||||
lines.removeFirst(lines.count - maxLines)
|
||||
}
|
||||
}
|
||||
|
||||
func clear() {
|
||||
lines.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func appLog(_ message: String) {
|
||||
Task { @MainActor in AppLogger.shared.log(message) }
|
||||
}
|
||||
@@ -203,6 +203,7 @@ private struct ArtistTrackRow: View {
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onPlay() }
|
||||
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
|
||||
.onChange(of: authManager.likedTrackIds) { _, ids in isLiked = ids.contains(track.id) }
|
||||
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
|
||||
TrackInfoView(track: track)
|
||||
}
|
||||
|
||||
@@ -190,6 +190,12 @@ struct CatalogService {
|
||||
_ = try await post(path: "api/player/devices/command", body: body)
|
||||
}
|
||||
|
||||
func lastfmNowPlaying(trackId: Int64) async throws {
|
||||
struct Body: Encodable { let trackId: Int64 }
|
||||
let body = try Self.deviceEncoder.encode(Body(trackId: trackId))
|
||||
_ = try await post(path: "api/player/lastfm/now-playing", body: body)
|
||||
}
|
||||
|
||||
func recordHistory(trackId: Int64, startedAt: Int64?, durationListened: Int32, completed: Bool) async throws {
|
||||
struct Body: Encodable {
|
||||
let trackId: Int64
|
||||
|
||||
@@ -36,6 +36,7 @@ struct ContentView: View {
|
||||
@State private var showingQueue = false
|
||||
@State private var showingDevices = false
|
||||
@State private var remoteSeekOverride: Double? = nil // optimistic seek for client mode
|
||||
@State private var shareCopied = false
|
||||
|
||||
// ViewModels bound to session (optional)
|
||||
@State private var globalVM: GlobalArtistsViewModel?
|
||||
@@ -64,6 +65,9 @@ struct ContentView: View {
|
||||
.onChange(of: player.currentTrack?.id) { _, newId in
|
||||
isCurrentTrackLiked = newId.map { authManager.likedTrackIds.contains($0) } ?? false
|
||||
}
|
||||
.onChange(of: authManager.likedTrackIds) { _, ids in
|
||||
isCurrentTrackLiked = player.currentTrack.map { ids.contains($0.id) } ?? false
|
||||
}
|
||||
.onChange(of: deviceManager.remoteState?.updatedAtMs) { _, _ in
|
||||
remoteSeekOverride = nil
|
||||
}
|
||||
@@ -103,9 +107,14 @@ struct ContentView: View {
|
||||
Task { await authManager.loadLikedIds() }
|
||||
deviceManager.start(service: service, player: player)
|
||||
let am = authManager
|
||||
player.onTrackStarted = { trackId in
|
||||
do { try await am.catalogService?.lastfmNowPlaying(trackId: trackId) }
|
||||
catch { appLog("❌ lastfm now-playing error: \(error)") }
|
||||
}
|
||||
player.onTrackFinished = { trackId, startedAt, durationListened, completed in
|
||||
guard let svc = am.catalogService else { return }
|
||||
try? await svc.recordHistory(trackId: trackId, startedAt: startedAt, durationListened: durationListened, completed: completed)
|
||||
do { try await svc.recordHistory(trackId: trackId, startedAt: startedAt, durationListened: durationListened, completed: completed) }
|
||||
catch { appLog("❌ history record error: \(error)") }
|
||||
}
|
||||
let gvm = globalVM
|
||||
Task { await gvm?.loadFirstPage() }
|
||||
@@ -552,9 +561,11 @@ struct ContentView: View {
|
||||
.disabled(player.currentTrack == nil)
|
||||
|
||||
Button(action: shareCurrentTrack) {
|
||||
Image(systemName: "square.and.arrow.up").font(.system(size: 14))
|
||||
Image(systemName: shareCopied ? "checkmark" : "square.and.arrow.up")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain).foregroundColor(.secondary)
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(shareCopied ? .green : .secondary)
|
||||
.disabled(player.currentTrack == nil)
|
||||
|
||||
Button(action: { showingTrackInfo.toggle() }) {
|
||||
@@ -564,7 +575,7 @@ struct ContentView: View {
|
||||
.disabled(player.currentTrack == nil)
|
||||
.popover(isPresented: $showingTrackInfo, arrowEdge: .bottom) {
|
||||
if let track = player.currentTrack {
|
||||
TrackInfoView(track: track)
|
||||
PlayerTrackInfoView(track: track, service: authManager.catalogService)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -698,6 +709,11 @@ struct ContentView: View {
|
||||
let url = "\(baseUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")))/share/track/\(track.id)"
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(url, forType: .string)
|
||||
withAnimation(.easeIn(duration: 0.1)) { shareCopied = true }
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||
withAnimation(.easeOut(duration: 0.4)) { shareCopied = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,9 @@ final class DeviceManager {
|
||||
)
|
||||
let response = try decoder.decode(DevicePollResponseDTO.self, from: data)
|
||||
applyPollResponse(response, player: player)
|
||||
} catch { }
|
||||
} catch {
|
||||
appLog("❌ Device poll error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func applyPollResponse(_ response: DevicePollResponseDTO, player: PlayerManager) {
|
||||
@@ -104,10 +106,11 @@ final class DeviceManager {
|
||||
activeDeviceId = response.activeDeviceId
|
||||
|
||||
if isThisDeviceActive {
|
||||
if !wasActive { appLog("📱 This device became active") }
|
||||
remoteState = nil
|
||||
} else {
|
||||
if wasActive { appLog("📱 This device became inactive, active: \(response.activeDeviceId ?? "none")") }
|
||||
remoteState = response.playbackState
|
||||
// Pause local playback when this device is no longer active
|
||||
if wasActive {
|
||||
player.pauseIfNeeded()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// LogsView.swift
|
||||
// furumi_macos
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct LogsView: View {
|
||||
@State private var logger = AppLogger.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Logs")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text("\(logger.lines.count) lines")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Button("Clear") { logger.clear() }
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(logger.lines.enumerated()), id: \.offset) { idx, line in
|
||||
Text(line)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(color(for: line))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 1)
|
||||
.id(idx)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.onChange(of: logger.lines.count) { _, _ in
|
||||
if let last = logger.lines.indices.last {
|
||||
proxy.scrollTo(last, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let last = logger.lines.indices.last {
|
||||
proxy.scrollTo(last, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 680, height: 420)
|
||||
}
|
||||
|
||||
private func color(for line: String) -> Color {
|
||||
if line.contains("error") || line.contains("Error") || line.contains("❌") { return .red }
|
||||
if line.contains("warn") || line.contains("Warn") || line.contains("⚠️") { return .orange }
|
||||
if line.contains("▶") { return .accentColor }
|
||||
return .primary
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ final class PlayerManager {
|
||||
var canGoPrevious: Bool { currentIndex > 0 }
|
||||
var formattedCurrentTime: String { formatTime(currentTime) }
|
||||
|
||||
var onTrackStarted: ((Int64) async -> Void)?
|
||||
var onTrackFinished: ((Int64, Int64?, Int32, Bool) async -> Void)?
|
||||
|
||||
private var player: AVPlayer?
|
||||
@@ -96,12 +97,15 @@ final class PlayerManager {
|
||||
player?.play()
|
||||
isPlaying = true
|
||||
currentTrack = track
|
||||
appLog("▶ \(track.title) — \(track.artistNames)")
|
||||
currentTime = 0
|
||||
duration = Double(track.durationSeconds)
|
||||
trackStartedAt = Date()
|
||||
|
||||
addObserver()
|
||||
updateNowPlayingInfo(track: track)
|
||||
let cb = onTrackStarted
|
||||
Task { await cb?(track.id) }
|
||||
}
|
||||
|
||||
// MARK: - Public playback
|
||||
|
||||
@@ -172,6 +172,7 @@ private struct PlaylistTrackRow: View {
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onPlay() }
|
||||
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
|
||||
.onChange(of: authManager.likedTrackIds) { _, ids in isLiked = ids.contains(track.id) }
|
||||
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
|
||||
TrackInfoView(track: track)
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ private struct ReleaseTrackRow: View {
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onPlay() }
|
||||
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
|
||||
.onChange(of: authManager.likedTrackIds) { _, ids in isLiked = ids.contains(track.id) }
|
||||
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
|
||||
TrackInfoView(track: track)
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ private struct TrackRowSearch: View {
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onPlay() }
|
||||
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
|
||||
.onChange(of: authManager.likedTrackIds) { _, ids in isLiked = ids.contains(track.id) }
|
||||
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
|
||||
TrackInfoView(track: track)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,17 @@ struct SettingsView: View {
|
||||
Divider()
|
||||
authSection
|
||||
Spacer()
|
||||
Divider()
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
NSApp.sendAction(#selector(AppDelegate.openLogs), to: nil, from: nil)
|
||||
} label: {
|
||||
Label("View Logs", systemImage: "doc.text.magnifyingglass")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 360)
|
||||
|
||||
@@ -5,6 +5,24 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Wrapper used by the player bar — enriches track data from the API if fields are missing
|
||||
struct PlayerTrackInfoView: View {
|
||||
let track: TrackCard
|
||||
let service: CatalogService?
|
||||
|
||||
@State private var enriched: TrackCard?
|
||||
|
||||
var body: some View {
|
||||
TrackInfoView(track: enriched ?? track)
|
||||
.task(id: track.id) {
|
||||
guard track.audioFormat == nil, track.releaseId > 0, let service else { return }
|
||||
guard let detail = try? await service.releaseDetail(id: track.releaseId),
|
||||
let full = detail.tracks.first(where: { $0.id == track.id }) else { return }
|
||||
enriched = full
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TrackInfoView: View {
|
||||
let track: TrackCard
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
var statusItem: NSStatusItem!
|
||||
var popover: NSPopover!
|
||||
var settingsWindow: NSWindow?
|
||||
var logsWindow: NSWindow?
|
||||
let authManager = AuthManager()
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
@@ -108,6 +109,29 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
@objc func openLogs() {
|
||||
if let window = logsWindow {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 680, height: 420),
|
||||
styleMask: [.titled, .closable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Logs"
|
||||
window.contentViewController = NSHostingController(rootView: LogsView())
|
||||
window.center()
|
||||
window.isReleasedWhenClosed = false
|
||||
logsWindow = window
|
||||
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
@objc func quit() {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user