Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80aa04f625 | |||
| 88297cbc91 | |||
| ab352b9595 | |||
| 37a6ccaf0c |
@@ -409,7 +409,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "hexor.furumi-macos";
|
PRODUCT_BUNDLE_IDENTIFIER = "hexor.furumi-macos";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -442,7 +442,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "hexor.furumi-macos";
|
PRODUCT_BUNDLE_IDENTIFIER = "hexor.furumi-macos";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
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())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { onPlay() }
|
.onTapGesture { onPlay() }
|
||||||
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
|
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
|
||||||
|
.onChange(of: authManager.likedTrackIds) { _, ids in isLiked = ids.contains(track.id) }
|
||||||
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
|
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
|
||||||
TrackInfoView(track: track)
|
TrackInfoView(track: track)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,6 +190,12 @@ struct CatalogService {
|
|||||||
_ = try await post(path: "api/player/devices/command", body: body)
|
_ = 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 {
|
func recordHistory(trackId: Int64, startedAt: Int64?, durationListened: Int32, completed: Bool) async throws {
|
||||||
struct Body: Encodable {
|
struct Body: Encodable {
|
||||||
let trackId: Int64
|
let trackId: Int64
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ struct ContentView: View {
|
|||||||
@State private var showingQueue = false
|
@State private var showingQueue = false
|
||||||
@State private var showingDevices = false
|
@State private var showingDevices = false
|
||||||
@State private var remoteSeekOverride: Double? = nil // optimistic seek for client mode
|
@State private var remoteSeekOverride: Double? = nil // optimistic seek for client mode
|
||||||
|
@State private var shareCopied = false
|
||||||
|
|
||||||
// ViewModels bound to session (optional)
|
// ViewModels bound to session (optional)
|
||||||
@State private var globalVM: GlobalArtistsViewModel?
|
@State private var globalVM: GlobalArtistsViewModel?
|
||||||
@@ -64,6 +65,9 @@ struct ContentView: View {
|
|||||||
.onChange(of: player.currentTrack?.id) { _, newId in
|
.onChange(of: player.currentTrack?.id) { _, newId in
|
||||||
isCurrentTrackLiked = newId.map { authManager.likedTrackIds.contains($0) } ?? false
|
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
|
.onChange(of: deviceManager.remoteState?.updatedAtMs) { _, _ in
|
||||||
remoteSeekOverride = nil
|
remoteSeekOverride = nil
|
||||||
}
|
}
|
||||||
@@ -103,9 +107,14 @@ struct ContentView: View {
|
|||||||
Task { await authManager.loadLikedIds() }
|
Task { await authManager.loadLikedIds() }
|
||||||
deviceManager.start(service: service, player: player)
|
deviceManager.start(service: service, player: player)
|
||||||
let am = authManager
|
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
|
player.onTrackFinished = { trackId, startedAt, durationListened, completed in
|
||||||
guard let svc = am.catalogService else { return }
|
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
|
let gvm = globalVM
|
||||||
Task { await gvm?.loadFirstPage() }
|
Task { await gvm?.loadFirstPage() }
|
||||||
@@ -552,9 +561,11 @@ struct ContentView: View {
|
|||||||
.disabled(player.currentTrack == nil)
|
.disabled(player.currentTrack == nil)
|
||||||
|
|
||||||
Button(action: shareCurrentTrack) {
|
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)
|
.disabled(player.currentTrack == nil)
|
||||||
|
|
||||||
Button(action: { showingTrackInfo.toggle() }) {
|
Button(action: { showingTrackInfo.toggle() }) {
|
||||||
@@ -564,7 +575,7 @@ struct ContentView: View {
|
|||||||
.disabled(player.currentTrack == nil)
|
.disabled(player.currentTrack == nil)
|
||||||
.popover(isPresented: $showingTrackInfo, arrowEdge: .bottom) {
|
.popover(isPresented: $showingTrackInfo, arrowEdge: .bottom) {
|
||||||
if let track = player.currentTrack {
|
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)"
|
let url = "\(baseUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")))/share/track/\(track.id)"
|
||||||
NSPasteboard.general.clearContents()
|
NSPasteboard.general.clearContents()
|
||||||
NSPasteboard.general.setString(url, forType: .string)
|
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)
|
let response = try decoder.decode(DevicePollResponseDTO.self, from: data)
|
||||||
applyPollResponse(response, player: player)
|
applyPollResponse(response, player: player)
|
||||||
} catch { }
|
} catch {
|
||||||
|
appLog("❌ Device poll error: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyPollResponse(_ response: DevicePollResponseDTO, player: PlayerManager) {
|
private func applyPollResponse(_ response: DevicePollResponseDTO, player: PlayerManager) {
|
||||||
@@ -104,10 +106,11 @@ final class DeviceManager {
|
|||||||
activeDeviceId = response.activeDeviceId
|
activeDeviceId = response.activeDeviceId
|
||||||
|
|
||||||
if isThisDeviceActive {
|
if isThisDeviceActive {
|
||||||
|
if !wasActive { appLog("📱 This device became active") }
|
||||||
remoteState = nil
|
remoteState = nil
|
||||||
} else {
|
} else {
|
||||||
|
if wasActive { appLog("📱 This device became inactive, active: \(response.activeDeviceId ?? "none")") }
|
||||||
remoteState = response.playbackState
|
remoteState = response.playbackState
|
||||||
// Pause local playback when this device is no longer active
|
|
||||||
if wasActive {
|
if wasActive {
|
||||||
player.pauseIfNeeded()
|
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 canGoPrevious: Bool { currentIndex > 0 }
|
||||||
var formattedCurrentTime: String { formatTime(currentTime) }
|
var formattedCurrentTime: String { formatTime(currentTime) }
|
||||||
|
|
||||||
|
var onTrackStarted: ((Int64) async -> Void)?
|
||||||
var onTrackFinished: ((Int64, Int64?, Int32, Bool) async -> Void)?
|
var onTrackFinished: ((Int64, Int64?, Int32, Bool) async -> Void)?
|
||||||
|
|
||||||
private var player: AVPlayer?
|
private var player: AVPlayer?
|
||||||
@@ -96,12 +97,15 @@ final class PlayerManager {
|
|||||||
player?.play()
|
player?.play()
|
||||||
isPlaying = true
|
isPlaying = true
|
||||||
currentTrack = track
|
currentTrack = track
|
||||||
|
appLog("▶ \(track.title) — \(track.artistNames)")
|
||||||
currentTime = 0
|
currentTime = 0
|
||||||
duration = Double(track.durationSeconds)
|
duration = Double(track.durationSeconds)
|
||||||
trackStartedAt = Date()
|
trackStartedAt = Date()
|
||||||
|
|
||||||
addObserver()
|
addObserver()
|
||||||
updateNowPlayingInfo(track: track)
|
updateNowPlayingInfo(track: track)
|
||||||
|
let cb = onTrackStarted
|
||||||
|
Task { await cb?(track.id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public playback
|
// MARK: - Public playback
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ private struct PlaylistTrackRow: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { onPlay() }
|
.onTapGesture { onPlay() }
|
||||||
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
|
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
|
||||||
|
.onChange(of: authManager.likedTrackIds) { _, ids in isLiked = ids.contains(track.id) }
|
||||||
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
|
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
|
||||||
TrackInfoView(track: track)
|
TrackInfoView(track: track)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ private struct ReleaseTrackRow: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { onPlay() }
|
.onTapGesture { onPlay() }
|
||||||
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
|
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
|
||||||
|
.onChange(of: authManager.likedTrackIds) { _, ids in isLiked = ids.contains(track.id) }
|
||||||
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
|
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
|
||||||
TrackInfoView(track: track)
|
TrackInfoView(track: track)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ private struct TrackRowSearch: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { onPlay() }
|
.onTapGesture { onPlay() }
|
||||||
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
|
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
|
||||||
|
.onChange(of: authManager.likedTrackIds) { _, ids in isLiked = ids.contains(track.id) }
|
||||||
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
|
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
|
||||||
TrackInfoView(track: track)
|
TrackInfoView(track: track)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,17 @@ struct SettingsView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
authSection
|
authSection
|
||||||
Spacer()
|
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)
|
.padding(24)
|
||||||
.frame(width: 360)
|
.frame(width: 360)
|
||||||
|
|||||||
@@ -5,6 +5,24 @@
|
|||||||
|
|
||||||
import SwiftUI
|
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 {
|
struct TrackInfoView: View {
|
||||||
let track: TrackCard
|
let track: TrackCard
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
var statusItem: NSStatusItem!
|
var statusItem: NSStatusItem!
|
||||||
var popover: NSPopover!
|
var popover: NSPopover!
|
||||||
var settingsWindow: NSWindow?
|
var settingsWindow: NSWindow?
|
||||||
|
var logsWindow: NSWindow?
|
||||||
let authManager = AuthManager()
|
let authManager = AuthManager()
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
@@ -108,6 +109,29 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
NSApp.activate(ignoringOtherApps: true)
|
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() {
|
@objc func quit() {
|
||||||
NSApplication.shared.terminate(nil)
|
NSApplication.shared.terminate(nil)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user