-
Notifications
You must be signed in to change notification settings - Fork 0
fix(#265): モニタ抜き差し後のレイアウト崩れとアートワーク消失を修復 #266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0955cf3
c658c9f
3caf486
ca242c2
2634afb
740cdae
188edab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,10 @@ import Domain | |||||
| import Foundation | ||||||
| import os | ||||||
|
|
||||||
| /// Implementation of the `TrackInteractor` that manages the reactive track and artwork pipeline. | ||||||
| /// | ||||||
| /// It observes now-playing information, resolves metadata and lyrics, and handles | ||||||
| /// artwork state preservation across track updates. | ||||||
| public final class TrackInteractorImpl: @unchecked Sendable { | ||||||
| private let playbackService: any PlaybackUseCase | ||||||
| private let lyricsService: any LyricsUseCase | ||||||
|
|
@@ -31,6 +35,7 @@ public final class TrackInteractorImpl: @unchecked Sendable { | |||||
| } | ||||||
| .share() | ||||||
|
|
||||||
| /// A publisher that emits `TrackUpdate` events whenever the active track changes. | ||||||
| public lazy var trackChange: AnyPublisher<TrackUpdate, Never> = | ||||||
| activeNowPlaying | ||||||
| .removeDuplicates(by: Self.sameTrack) | ||||||
|
|
@@ -42,9 +47,18 @@ public final class TrackInteractorImpl: @unchecked Sendable { | |||||
| .share() | ||||||
| .eraseToAnyPublisher() | ||||||
|
|
||||||
| /// Keeps the last known artwork while the track is unchanged: system | ||||||
| /// re-broadcasts (e.g. during display reconfiguration) often omit the | ||||||
| /// artwork bytes, and a raw `map(\.artworkData)` would blank the artwork | ||||||
| /// until the next event that happens to carry them (#265). A track change | ||||||
| /// without artwork still clears it. | ||||||
| public lazy var artwork: AnyPublisher<Data?, Never> = | ||||||
| activeNowPlaying | ||||||
| .map(\.artworkData) | ||||||
| .scan((track: NowPlaying?.none, data: Data?.none)) { state, incoming in | ||||||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 同一トラック判定に
Suggested change
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||
| let isSameTrack = state.track.map { $0.title != nil && Self.sameTrack($0, incoming) } ?? false | ||||||
| return (track: incoming, data: incoming.artworkData ?? (isSameTrack ? state.data : nil)) | ||||||
| } | ||||||
| .map(\.data) | ||||||
| .removeDuplicates() | ||||||
| .eraseToAnyPublisher() | ||||||
|
|
||||||
|
|
@@ -60,6 +74,7 @@ public final class TrackInteractorImpl: @unchecked Sendable { | |||||
| return lhs.title == rhs.title && lhsArtist == rhsArtist | ||||||
| } | ||||||
|
|
||||||
| /// A publisher that emits the current playback position. | ||||||
| public lazy var playbackPosition: AnyPublisher<PlaybackPosition, Never> = | ||||||
| activeNowPlaying | ||||||
| .map { np in | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| 2.13.7 | ||
| 2.13.8 |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -17,6 +17,11 @@ protocol OverlayWindowSurface: AnyObject { | |||||||||
| func orderFront(_ sender: Any?) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /// The main overlay window for the application. | ||||||||||
| /// | ||||||||||
| /// It is a borderless `NSWindow` that covers the entire screen (or a specific area) | ||||||||||
| /// and hosts the SwiftUI overlay content. It handles geometry reconciliation, | ||||||||||
| /// wallpaper scaling, and AVPlayerLayer attachment. | ||||||||||
| @MainActor | ||||||||||
| public final class AppWindow: NSWindow { | ||||||||||
| static var overlayLevel: NSWindow.Level { | ||||||||||
|
|
@@ -33,6 +38,14 @@ public final class AppWindow: NSWindow { | |||||||||
|
|
||||||||||
| private let hostingView: NSHostingView<OverlayContentView> | ||||||||||
|
|
||||||||||
| /// Initializes the app window with the specified layout and presenters. | ||||||||||
| /// | ||||||||||
| /// - Parameters: | ||||||||||
| /// - initialLayout: The initial geometry for the window and hosting view. | ||||||||||
| /// - headerPresenter: Presenter for the header view. | ||||||||||
| /// - lyricsPresenter: Presenter for the lyrics view. | ||||||||||
| /// - ripplePresenter: Presenter for the ripple effect. | ||||||||||
| /// - wallpaperPresenter: Presenter for the wallpaper view. | ||||||||||
| public init( | ||||||||||
| initialLayout: ScreenLayout, | ||||||||||
| headerPresenter: HeaderPresenter, | ||||||||||
|
|
@@ -60,22 +73,33 @@ public final class AppWindow: NSWindow { | |||||||||
| Self.applyOverlayStyle(to: self, hostingView: hostingView) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /// Brings the window to the front. | ||||||||||
| public func show() { | ||||||||||
| Self.present(self) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /// Updates the window and hosting view geometry. | ||||||||||
| /// | ||||||||||
| /// - Parameter layout: The new layout to apply. | ||||||||||
| public func applyLayout(_ layout: ScreenLayout) { | ||||||||||
| Self.apply(layout: layout, to: self, hostingView: hostingView) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /// Attaches an `AVPlayer` layer to the window for wallpaper playback. | ||||||||||
| /// | ||||||||||
| /// - Parameter player: The player to attach. | ||||||||||
| public func attachPlayerLayer(for player: AVPlayer) { | ||||||||||
| Self.attachPlayer(player, to: self, hostingView: hostingView) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /// Applies an affine transform scale to the wallpaper player layer. | ||||||||||
| /// | ||||||||||
| /// - Parameter scale: The scale factor to apply. | ||||||||||
| public func applyWallpaperScale(_ scale: Double) { | ||||||||||
| Self.applyWallpaperScale(scale, to: self) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /// Hides the window and calls the superclass `close`. | ||||||||||
| public override func close() { | ||||||||||
| orderOut(nil) | ||||||||||
| super.close() | ||||||||||
|
|
@@ -103,12 +127,37 @@ extension AppWindow { | |||||||||
| surface.orderFront(nil) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /// Re-asserts the full window/view/layer geometry from the resolved layout. | ||||||||||
| /// Idempotent by design: the window server mutates the actual window during | ||||||||||
| /// display reconfiguration (#265), so every call reconciles against actual | ||||||||||
| /// state instead of trusting that earlier applications are still in effect. | ||||||||||
| /// Skipping `setFrame` when the frame already matches keeps the | ||||||||||
| /// `NSWindow.didMove/didResize → screenChanges → apply` cycle from looping. | ||||||||||
| static func apply(layout: ScreenLayout, to surface: OverlayWindowSurface, hostingView: NSView) { | ||||||||||
| surface.setFrame(layout.windowFrame, display: false) | ||||||||||
| hostingView.frame = layout.hostingFrame | ||||||||||
| if let containerView = surface.contentView, containerView !== hostingView { | ||||||||||
| containerView.frame = CGRect(origin: .zero, size: layout.windowFrame.size) | ||||||||||
| } | ||||||||||
| applyWindowFrame(layout.windowFrame, to: surface) | ||||||||||
| applyFrame(layout.hostingFrame, to: hostingView) | ||||||||||
| guard let containerView = surface.contentView, containerView !== hostingView else { return } | ||||||||||
| applyFrame(contentFrame(for: layout.windowFrame), to: containerView) | ||||||||||
| guard let playerLayer = playerLayer(in: surface) else { return } | ||||||||||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||
| reassertGeometry(of: playerLayer, in: containerView.bounds) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| private static func applyWindowFrame(_ frame: NSRect, to surface: OverlayWindowSurface) { | ||||||||||
| guard surface.frame != frame else { return } | ||||||||||
| surface.setFrame(frame, display: false) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| private static func applyFrame(_ frame: CGRect, to view: NSView) { | ||||||||||
| guard view.frame != frame else { return } | ||||||||||
| view.frame = frame | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /// Sets `bounds` + `position` rather than `frame`: the layer carries the | ||||||||||
| /// wallpaper-scale affine transform, and `frame` is undefined under a | ||||||||||
| /// non-identity transform. | ||||||||||
| private static func reassertGeometry(of playerLayer: AVPlayerLayer, in bounds: CGRect) { | ||||||||||
| playerLayer.bounds = bounds | ||||||||||
| playerLayer.position = CGPoint(x: bounds.midX, y: bounds.midY) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| static func attachPlayer( | ||||||||||
|
|
||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
.removeDuplicates()を削除したことで意図通り「自己修復」が可能になりましたが、高頻度な通知が発生した場合に備え、必要であればapplyLayoutの直前でthrottleやdebounceを入れることも検討の余地があります。現状のapplyが十分に軽量であればこのままで問題ありません。There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
現状
とします。
applyは全ジオメトリ書き込みが一致時スキップの冪等実装になっており(188edab で view frame もスキップ対象に追加)、無変化シグナルの実コストはほぼ比較のみです。またdidMove/didResizeが高頻度になるのはディスプレイ再構成中の短いバーストに限られ、throttle/debounce を挟むとまさに #265 の自己修復が遅延する側に働くため、入れない判断としました。