Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions Sources/Kaset/MusicIslandWindowController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import AppKit
import SwiftUI

// MARK: - MusicIslandWindowController

/// Manages the floating Music Island window.
@available(macOS 26.0, *)
@MainActor
final class MusicIslandWindowController {
static let shared = MusicIslandWindowController()

private var window: NSWindow?
private var isVisible = false

private init() {}

/// Shows or updates the music island window.
func show(
playerService: PlayerService,
lyricsService: SyncedLyricsService
) {
if let existingWindow = self.window {
if !self.isVisible {
existingWindow.orderFront(nil)
self.isVisible = true
}
return
}

let contentView = MusicIslandView()
.environment(playerService)
.environment(lyricsService)

let hostingView = NSHostingView(rootView: AnyView(contentView))

let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 498, height: 116),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
Comment on lines +36 to +41

window.contentView = hostingView
window.isReleasedWhenClosed = false
window.isOpaque = false
window.backgroundColor = .clear
window.hasShadow = false
window.level = .popUpMenu // Always on top of everything
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]

// Position at top center
self.positionAtTopCenter(window: window)

// Show without stealing focus
window.orderFrontRegardless()

Check failure on line 56 in Sources/Kaset/MusicIslandWindowController.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
self.window = window
self.isVisible = true

Check failure on line 59 in Sources/Kaset/MusicIslandWindowController.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
// Observe screen changes to reposition
NotificationCenter.default.addObserver(
self,
selector: #selector(self.screenParametersChanged),
name: NSApplication.didChangeScreenParametersNotification,
object: nil
)
}

/// Hides the music island window.
func hide() {
guard let window = self.window, self.isVisible else { return }
window.orderOut(nil)
self.isVisible = false
}

@objc private func screenParametersChanged() {
guard let window = self.window, self.isVisible else { return }
self.positionAtTopCenter(window: window)
}

private func positionAtTopCenter(window: NSWindow) {
guard let screen = NSScreen.main else { return }
let screenFrame = screen.frame // Use absolute frame to overlap the menu bar
let windowSize = window.frame.size

Check failure on line 85 in Sources/Kaset/MusicIslandWindowController.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
// 0px margin from the absolute top of the screen to extend the dark spot
let origin = NSPoint(
x: screenFrame.midX - (windowSize.width / 2),
y: screenFrame.maxY - windowSize.height
)

window.setFrameOrigin(origin)
}
}
9 changes: 9 additions & 0 deletions Sources/Kaset/Services/SettingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ final class SettingsManager {
static let syncedLyricsEnabled = "settings.syncedLyricsEnabled"
static let romanizationEnabled = "settings.romanizationEnabled"
static let contentLanguage = "settings.contentLanguage"
static let musicIslandEnabled = "settings.musicIslandEnabled"
}

// MARK: - Launch Page Options
Expand Down Expand Up @@ -273,6 +274,13 @@ final class SettingsManager {
}
}

/// Whether the Music Island feature (floating lyrics) is enabled.
var musicIslandEnabled: Bool {
didSet {
UserDefaults.standard.set(self.musicIslandEnabled, forKey: Keys.musicIslandEnabled)
}
}

/// The language used for the app interface and API content.
var contentLanguage: ContentLanguage {
didSet {
Expand Down Expand Up @@ -303,6 +311,7 @@ final class SettingsManager {
self.scrobbleMinSeconds = UserDefaults.standard.object(forKey: Keys.scrobbleMinSeconds) as? Double ?? 240
self.syncedLyricsEnabled = UserDefaults.standard.object(forKey: Keys.syncedLyricsEnabled) as? Bool ?? true
self.romanizationEnabled = UserDefaults.standard.object(forKey: Keys.romanizationEnabled) as? Bool ?? true
self.musicIslandEnabled = UserDefaults.standard.object(forKey: Keys.musicIslandEnabled) as? Bool ?? false

if let rawValue = UserDefaults.standard.string(forKey: Keys.mediaControlStyle),
let style = MediaControlStyle(rawValue: rawValue)
Expand Down
4 changes: 4 additions & 0 deletions Sources/Kaset/Views/GeneralSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ struct GeneralSettingsView: View {
Toggle("Enable Synced Lyrics", isOn: self.$settings.syncedLyricsEnabled)
.help("Fetch and display real-time synced lyrics when available")

// Music Island
Toggle("Music Island", isOn: self.$settings.musicIslandEnabled)
.help("Show a floating capsule at the top of the screen with currently playing lyrics")

// Romanization
Toggle("Romanize Lyrics", isOn: self.$settings.romanizationEnabled)
.help("Show romanized text (romaji, pinyin, etc.) below non-Latin lyrics")
Expand Down
19 changes: 19 additions & 0 deletions Sources/Kaset/Views/MainWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ struct MainWindow: View {
@Environment(AccountService.self) private var accountService
@Environment(SongLikeStatusManager.self) private var likeStatusManager
@Environment(PodcastsAvailabilityService.self) private var podcastsAvailability
@Environment(SyncedLyricsService.self) private var lyricsService
@Environment(\.searchFocusTrigger) private var searchFocusTrigger
@Environment(\.showCommandBar) private var showCommandBar
@Environment(\.showWhatsNew) private var showWhatsNew
Expand All @@ -38,6 +39,7 @@ struct MainWindow: View {
@State private var isCommandBarPresented = false
@State private var whatsNewToPresent: PresentedWhatsNew?
@State private var selectedSidebarPinnedItem: SidebarPinnedItem?
@State private var settings = SettingsManager.shared

// MARK: - Cached ViewModels (persist across tab switches)

Expand Down Expand Up @@ -213,6 +215,12 @@ struct MainWindow: View {
VideoWindowController.shared.close()
}
}
.onChange(of: self.settings.musicIslandEnabled) { _, _ in
self.updateMusicIslandState()
}
.onChange(of: self.playerService.isPlaying) { _, _ in
self.updateMusicIslandState()
}
.onChange(of: self.accountService.currentAccount?.id) { _, newAccountId in
self.playerService.resetTrackStatus()
self.podcastsViewModel?.configure(
Expand Down Expand Up @@ -326,6 +334,17 @@ struct MainWindow: View {
}
}

private func updateMusicIslandState() {
if self.settings.musicIslandEnabled && self.playerService.isPlaying {
MusicIslandWindowController.shared.show(
playerService: self.playerService,
lyricsService: self.lyricsService
)
} else {
MusicIslandWindowController.shared.hide()
}
}

// MARK: - Main Content

private var mainContent: some View {
Expand Down
161 changes: 161 additions & 0 deletions Sources/Kaset/Views/MusicIslandView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import SwiftUI

/// A floating Dynamic Island-style view for displaying playback info and lyrics.
@available(macOS 26.0, *)
struct MusicIslandView: View {
@Environment(PlayerService.self) private var playerService
@Environment(SyncedLyricsService.self) private var lyricsService

@State private var isHovered = false

var body: some View {
HStack(alignment: .top, spacing: 14) {
// Album Art
if let url = self.playerService.currentTrack?.thumbnailURL {
AsyncImage(url: url) { phase in
if let image = phase.image {
image.resizable().aspectRatio(contentMode: .fill)
} else if phase.error != nil {
CassetteIcon(size: 48)
} else {
ProgressView().controlSize(.small)
}
}
.frame(width: 64, height: 64)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
} else {
CassetteIcon(size: 64)
}

VStack(alignment: .leading, spacing: 2) {
if let line = self.currentLyricLine {
Text(line.text.isEmpty ? "♪" : line.text)
.font(.system(size: 15, weight: .semibold, design: .rounded))
.foregroundStyle(.primary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
.contentTransition(.numericText())
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: line.text)

Check failure on line 39 in Sources/Kaset/Views/MusicIslandView.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
if let romaji = line.romanizedText {
Text(romaji)
.font(.system(size: 12, weight: .medium, design: .rounded))
.foregroundStyle(.secondary)
.lineLimit(1)
.contentTransition(.numericText())
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: romaji)
}
} else {
Text(self.playerService.currentTrack?.title ?? "Kaset")
.font(.system(size: 15, weight: .semibold, design: .rounded))
.foregroundStyle(.primary)
.lineLimit(1)

Check failure on line 53 in Sources/Kaset/Views/MusicIslandView.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
Text(self.playerService.currentTrack?.artistsDisplay ?? "Not Playing")
.font(.system(size: 12, weight: .medium, design: .rounded))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(.top, 24) // Anchor text below the notch so it safely grows downwards

Spacer(minLength: 0)
}
.padding(.horizontal, 16)
.padding(.top, 12) // Smaller top padding so the thumbnail can expand upwards
.padding(.bottom, 16)
.frame(minWidth: 200, idealWidth: 320, maxWidth: 450)
// Absolute black to blend with physical notch
.background(
UnevenRoundedRectangle(bottomLeadingRadius: 24, bottomTrailingRadius: 24, style: .continuous)
.fill(.black)
.shadow(color: .black.opacity(0.5), radius: 15, x: 0, y: 8)
)
.overlay(alignment: .topTrailing) {
if self.isHovered {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.secondary)
.transition(.scale.combined(with: .opacity))
.padding(.top, 16)
.padding(.trailing, 16)
}
}
// Add outer padding to give room for the shadow in the window bounds
.padding(.horizontal, 24)
.padding(.bottom, 24)
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.2)) {
self.isHovered = hovering
}
}
.onTapGesture {
self.bringAppToFront()
}
.task(id: self.playerService.currentTrack?.videoId) {
if let videoId = self.playerService.currentTrack?.videoId {
await self.fetchLyrics(for: videoId)
}
}
.onChange(of: self.lyricsService.currentLyrics) { _, newLyrics in
self.updateLyricsPolling(for: newLyrics)
}
.onDisappear {
SingletonPlayerWebView.shared.stopLyricsPoll()
}
.onAppear {
self.updateLyricsPolling(for: self.lyricsService.currentLyrics)
}
}

private func updateLyricsPolling(for result: LyricResult) {
if case .synced = result {
SingletonPlayerWebView.shared.startLyricsPoll()
} else {
SingletonPlayerWebView.shared.stopLyricsPoll()
}
}

private func fetchLyrics(for videoId: String) async {
guard let track = self.playerService.currentTrack, track.videoId == videoId else { return }

Check failure on line 121 in Sources/Kaset/Views/MusicIslandView.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
let info = LyricsSearchInfo(
title: track.title,
artist: track.artistsDisplay,
album: track.album?.title,
duration: track.duration,
videoId: track.videoId
)

Check failure on line 129 in Sources/Kaset/Views/MusicIslandView.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
if SettingsManager.shared.syncedLyricsEnabled {
await self.lyricsService.fetchLyrics(for: info)
}
}

private var currentLyricLine: SyncedLyricLine? {
guard case let .synced(syncedLyrics) = self.lyricsService.currentLyrics else {
return nil
}
let currentTime = self.playerService.currentTimeMs
if let idx = syncedLyrics.currentLineIndex(at: currentTime) {
return syncedLyrics.lines[idx]
}
return nil
}

private func bringAppToFront() {
for window in NSApplication.shared.windows where window.frameAutosaveName == "KasetMainWindow" {
window.makeKeyAndOrderFront(nil)
NSApplication.shared.activate(ignoringOtherApps: true)
return
}

Check failure on line 152 in Sources/Kaset/Views/MusicIslandView.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Lines should not have trailing whitespace (trailing_whitespace)
// Fallback
for window in NSApplication.shared.windows where window.canBecomeMain {
if window.identifier?.rawValue == AccessibilityID.VideoWindow.container { continue }
window.makeKeyAndOrderFront(nil)
NSApplication.shared.activate(ignoringOtherApps: true)
return
}
}
}
Loading