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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,4 @@ TEMP_*
build/
.codex/environments/environment.toml
Build
.jj
1 change: 1 addition & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ disabled_rules:
- trailing_comma
- line_length
- opening_brace # Allow SwiftFormat's multi-line condition style
- no_dispatch_queue # NWListener and NWConnection require DispatchQueue for thread safety

opt_in_rules:
- closure_spacing
Expand Down
8 changes: 8 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ let package = Package(
exclude: [
"Resources/AppIcon.icon",
"Resources/kaset.icns",
// Legacy .lproj directories conflict with Localizable.xcstrings
// compilation (both produce the same .strings output files).
// The .xcstrings catalog is the canonical localization source.
"Resources/ar.lproj",
"Resources/fr.lproj",
"Resources/id.lproj",
"Resources/ko.lproj",
"Resources/tr.lproj",
],
resources: [
.process("Resources/Assets.xcassets"),
Expand Down
75 changes: 69 additions & 6 deletions Scripts/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,14 @@ echo "🔨 Building $APP_NAME ($CONF) for ${ARCH_LIST[*]}..."
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR"

# Build for each architecture
# Build for all architectures in a single invocation (produces universal binaries or respects host settings)
SWIFT_BUILD_ARGS=( -c "$CONF" --product "$APP_NAME" )
for ARCH in "${ARCH_LIST[@]}"; do
echo " → Building for $ARCH..."
swift build -c "$CONF" --arch "$ARCH" --product "$APP_NAME"
SWIFT_BUILD_ARGS+=( --arch "$ARCH" )
done

swift build "${SWIFT_BUILD_ARGS[@]}"

# Create app bundle structure
echo "📦 Creating app bundle..."
mkdir -p "$APP_BUNDLE/Contents/MacOS"
Expand All @@ -53,6 +55,62 @@ mkdir -p "$APP_BUNDLE/Contents/Frameworks"
build_product_path() {
local name="$1"
local arch="$2"

# 1. Try to query bin path dynamically from swift build
if [[ -n "$name" ]]; then
local show_bin
show_bin=$(swift build -c "$CONF" --arch "$arch" --show-bin-path 2>/dev/null || true)
if [[ -n "$show_bin" ]] && [[ -f "$show_bin/$name" ]]; then
echo "$show_bin/$name"
return 0
fi
fi

# 2. Check Xcode build system default paths (capitalized or lowercase Products directory)
local conf_cap
conf_cap="$(tr '[:lower:]' '[:upper:]' <<< "${CONF:0:1}")${CONF:1}"
if [[ -n "$name" ]]; then
if [[ -f ".build/out/Products/$conf_cap/$name" ]]; then
echo ".build/out/Products/$conf_cap/$name"
return 0
fi
if [[ -f ".build/out/Products/$CONF/$name" ]]; then
echo ".build/out/Products/$CONF/$name"
return 0
fi
else
if [[ -d ".build/out/Products/$conf_cap" ]]; then
echo ".build/out/Products/$conf_cap"
return 0
fi
if [[ -d ".build/out/Products/$CONF" ]]; then
echo ".build/out/Products/$CONF"
return 0
fi
fi

# 3. Check classic architecture-specific paths
if [[ -n "$name" ]]; then
if [[ -f ".build/${arch}-apple-macosx/$CONF/$name" ]]; then
echo ".build/${arch}-apple-macosx/$CONF/$name"
return 0
fi
if [[ -f ".build/$CONF/$name" ]]; then
echo ".build/$CONF/$name"
return 0
fi
else
if [[ -d ".build/${arch}-apple-macosx/$CONF" ]]; then
echo ".build/${arch}-apple-macosx/$CONF"
return 0
fi
if [[ -d ".build/$CONF" ]]; then
echo ".build/$CONF"
return 0
fi
fi

# Fallback to the original logic
case "$arch" in
arm64|x86_64) echo ".build/${arch}-apple-macosx/$CONF/$name" ;;
*) echo ".build/$CONF/$name" ;;
Expand Down Expand Up @@ -122,10 +180,15 @@ install_binary() {
fi
binaries+=("$src")
done
if [[ ${#ARCH_LIST[@]} -gt 1 ]]; then
lipo -create "${binaries[@]}" -output "$dest"

# Remove duplicate paths if they compiled to the same output file
local unique_binaries
unique_binaries=($(printf "%s\n" "${binaries[@]}" | sort -u))

if [[ ${#unique_binaries[@]} -eq 1 ]]; then
cp "${unique_binaries[0]}" "$dest"
else
cp "${binaries[0]}" "$dest"
lipo -create "${unique_binaries[@]}" -output "$dest"
fi
chmod +x "$dest"
verify_binary_arches "$dest" "${ARCH_LIST[@]}"
Expand Down
2 changes: 1 addition & 1 deletion Sources/Kaset/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_: Notification) {
DiagnosticsLogger.app.info("AppDelegate: applicationDidFinishLaunching")
// Set up notification center delegate to show notifications in foreground
if !UITestConfig.isRunningUnitTests {
if Bundle.main.bundleIdentifier != nil, !UITestConfig.isRunningUnitTests {
UNUserNotificationCenter.current().delegate = self
}

Expand Down
169 changes: 111 additions & 58 deletions Sources/Kaset/KasetApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ struct KasetApp: App {
@State private var syncedLyricsService: SyncedLyricsService
@State private var equalizerService = EqualizerService.shared
@State private var settings = SettingsManager.shared
@State private var boringNotchBridge: BoringNotchBridgeService?
@State private var podcastsAvailabilityService = PodcastsAvailabilityService()

/// Triggers search field focus when set to true.
Expand Down Expand Up @@ -126,6 +127,7 @@ struct KasetApp: App {
]))
_notificationService = State(initialValue: NotificationService(playerService: player))
_accountService = State(initialValue: account)
_boringNotchBridge = State(initialValue: BoringNotchBridgeService(playerService: player))

// Create scrobbling coordinator
let lastFMService = LastFMService(credentialStore: KeychainCredentialStore())
Expand Down Expand Up @@ -185,6 +187,11 @@ struct KasetApp: App {
self.appDelegate.playerService = self.playerService
// Reference notificationService to keep SwiftUI from deallocating it
_ = self.notificationService
// Start boring.notch bridge if enabled
if self.settings.boringNotchBridgeEnabled {
DiagnosticsLogger.network.info("boring.notch integration enabled at launch; starting bridge")
self.boringNotchBridge?.start()
}
}
.task {
DiagnosticsLogger.app.info("KasetApp: Root task started")
Expand All @@ -203,65 +210,18 @@ struct KasetApp: App {
.onOpenURL { url in
self.handleIncomingURL(url)
}
.onChange(of: self.playerService.isPlaying) { _, isPlaying in
// The Core Audio process tap needs WebKit's GPU
// process to be actively emitting audio before it
// can be discovered. When playback starts, give the
// equalizer a chance to spin up.
if isPlaying {
self.equalizerService.retryStartIfEnabled()
// One audio source at a time: music starting pauses video.
self.playbackArbiter.musicDidStartPlaying()
}
}
.onChange(of: self.youtubePlayerService.surfaceLocation) { _, location in
// The floating window hosts the video surface whenever it
// is popped out (or the inline watch view went away).
if location == .floating {
YouTubeVideoWindowController.shared.show(
youtubePlayerService: self.youtubePlayerService
)
} else {
YouTubeVideoWindowController.shared.close()
}
}
.onChange(of: self.youtubePlayerService.popInRequest) { _, request in
// Pop-in from the floating window: bring the app to the
// video source; YouTubeContentView opens/adopts the
// watch view and consumes the request.
guard request != nil else { return }
self.settings.appSource = .video
self.showMainWindow()
}
.task {
NowPlayingManager.shared.configureYouTubeRouting(
.modifier(
PlaybackChangeHandlers(
playerService: self.playerService,
youtubePlayerService: self.youtubePlayerService,
arbiter: self.playbackArbiter
equalizerService: self.equalizerService,
playbackArbiter: self.playbackArbiter,
syncedLyricsService: self.syncedLyricsService,
sharedClient: self.sharedClient,
settings: self.settings,
showMainWindow: self.showMainWindow
)
}
.onChange(of: self.playerService.isMiniPlayerVisible) { _, isVisible in
if isVisible {
MiniPlayerWindowController.shared.show(
playerService: self.playerService,
client: self.sharedClient,
syncedLyricsService: self.syncedLyricsService
)
if self.playerService.miniPlayerMode == .switchFromMainWindow {
self.hideMainWindow()
}
} else {
MiniPlayerWindowController.shared.close()
if self.playerService.consumeMiniPlayerMainWindowRestoreRequest() {
self.showMainWindow()
}
}
}
.onChange(of: self.playerService.miniPlayerPanel) { _, _ in
MiniPlayerWindowController.shared.syncWindowState()
}
.onChange(of: self.settings.keepMiniPlayerOnTop) { _, _ in
MiniPlayerWindowController.shared.syncWindowState()
}
)
}
}
.defaultSize(width: MainWindowLayout.defaultWidth, height: MainWindowLayout.defaultHeight)
Expand Down Expand Up @@ -534,7 +494,7 @@ struct KasetApp: App {
return false
}

private static func isAuxiliaryPlayerWindow(_ window: NSWindow) -> Bool {
fileprivate static func isAuxiliaryPlayerWindow(_ window: NSWindow) -> Bool {
AccessibilityID.isAuxiliaryPlayerWindowIdentifier(window.identifier?.rawValue)
}

Expand Down Expand Up @@ -684,3 +644,96 @@ struct SettingsView: View {
.frame(width: 520, height: 520)
}
}

// MARK: - PlaybackChangeHandlers

/// Extracted from the `KasetApp.body` view builder to reduce type-checker
/// complexity ("unable to type-check this expression in reasonable time").
private struct PlaybackChangeHandlers: ViewModifier {
let playerService: PlayerService
let youtubePlayerService: YouTubePlayerService
let equalizerService: EqualizerService
let playbackArbiter: PlaybackArbiter
let syncedLyricsService: SyncedLyricsService
let sharedClient: any YTMusicClientProtocol
let settings: SettingsManager
let showMainWindow: () -> Void

func body(content: Content) -> some View {
content
.onChange(of: self.playerService.isPlaying) { _, isPlaying in
// The Core Audio process tap needs WebKit's GPU
// process to be actively emitting audio before it
// can be discovered. When playback starts, give the
// equalizer a chance to spin up.
if isPlaying {
self.equalizerService.retryStartIfEnabled()
// One audio source at a time: music starting pauses video.
self.playbackArbiter.musicDidStartPlaying()
}
}
.onChange(of: self.youtubePlayerService.surfaceLocation) { _, location in
// The floating window hosts the video surface whenever it
// is popped out (or the inline watch view went away).
if location == .floating {
YouTubeVideoWindowController.shared.show(
youtubePlayerService: self.youtubePlayerService
)
} else {
YouTubeVideoWindowController.shared.close()
}
}
.onChange(of: self.youtubePlayerService.popInRequest) { _, request in
// Pop-in from the floating window: bring the app to the
// video source; YouTubeContentView opens/adopts the
// watch view and consumes the request.
guard request != nil else { return }
self.settings.appSource = .video
self.showMainWindow()
}
.task {
NowPlayingManager.shared.configureYouTubeRouting(
youtubePlayerService: self.youtubePlayerService,
arbiter: self.playbackArbiter
)
}
.onChange(of: self.playerService.isMiniPlayerVisible) { _, isVisible in
if isVisible {
MiniPlayerWindowController.shared.show(
playerService: self.playerService,
client: self.sharedClient,
syncedLyricsService: self.syncedLyricsService
)
if self.playerService.miniPlayerMode == .switchFromMainWindow {
self.hideMainWindow()
}
} else {
MiniPlayerWindowController.shared.close()
if self.playerService.consumeMiniPlayerMainWindowRestoreRequest() {
self.showMainWindow()
}
}
}
.onChange(of: self.playerService.miniPlayerPanel) { _, _ in
MiniPlayerWindowController.shared.syncWindowState()
}
.onChange(of: self.settings.keepMiniPlayerOnTop) { _, _ in
MiniPlayerWindowController.shared.syncWindowState()
}
}

private func hideMainWindow() {
for window in NSApplication.shared.windows where window.frameAutosaveName == MainWindowLayout.autosaveName {
window.orderOut(nil)
return
}

for window in NSApplication.shared.windows where window.canBecomeMain {
if KasetApp.isAuxiliaryPlayerWindow(window) {
continue
}
window.orderOut(nil)
return
}
}
}
Loading
Loading