From 53a53fddaa131a32c3855bc3731f4e638d251252 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 12:45:46 +0900 Subject: [PATCH 01/44] docs: add Phase B7 checklist for RichHome layout/badge/naming/artwork fixes Co-Authored-By: Claude Opus 4.7 --- docs/progress.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/progress.md b/docs/progress.md index 9b6aca5..d5e6c2d 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -735,6 +735,25 @@ Phase B5完了後、実機で追加のフィードバックが8件出た。う Phase B6は8タスク全完了。実機検証は次回実機起動時に通しで実施予定。 +### Phase B7: RichHomeレイアウト微調整 + バッジ + 命名整理 + アートワーク安定化(2026-07-31〜) + +Phase B6完了後の実機フィードバックで、歌詞ボックスの垂直センタリング未解決(横ではなく縦方向)、 +シークバーとアートワークの間隔、再生元バッジの不要な黒円+アイコンサイズ、 +`AtollStyleExpandedView`という参考先アプリ名を含む命名の中立化、Spotify/Apple Musicの +アートワーク取得不安定の計6件の指摘があった。詳細な真因分析は +`~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`のPhase B7セクション参照。 + +- [ ] B7-1: 命名整理 — `AtollStyleExpandedView` → `RichHomeView`(ファイル名・型名・ + 12ファイルのコメント参照)に機械的リネーム。過去のGit履歴は書き換えない + (AskUserQuestionでユーザーと合意済み) +- [ ] B7-2: 歌詞ボックスの縦方向センタリング — `RichHomeView.swift`のHStack + `alignment: .top` → `.center`。あわせて`.frame(idealWidth: SurfaceMetrics.baseContentWidth)` + 追加で横方向のfixedSize/nilプロポーザル起因の中央寄せ崩れも修正 +- [ ] B7-3: シークバーとアートワークの間隔調整 — `progressSection`に`.padding(.top, 8)`追加 +- [ ] B7-4: 再生元バッジの黒円削除+アイコン拡大(18pt→40pt) +- [ ] B7-5: Spotifyアートワークキャッシュの成功後書き込み化(`ArtworkFetcher.swift`) +- [ ] B7-6: Apple Musicアートワークのバウンド付き再試行(`NowPlayingManager.swift`) + ### Phase D: バッテリー監視・アニメーション本格実装(未着手・タスク分割のみ) **参照**: `docs/macOS-Battery-Monitoring-Animation-Handbook-ja.md`(全39章、 From 77383b0c826e764e988f3c9cc1d9eec09cf0d1c7 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 12:49:02 +0900 Subject: [PATCH 02/44] refactor: rename AtollStyleExpandedView to RichHomeView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the type/file and rewords the handful of comments that named a specific reference app, replacing them with neutral descriptions of the actual design intent (rounder pill chrome, fuller rich-mode layout). Purely mechanical — no behavior change. Co-Authored-By: Claude Opus 4.7 --- perch/Core/Preferences.swift | 2 +- perch/Core/UIMode.swift | 4 ++-- perch/Features/Calendar/CalendarStandaloneView.swift | 2 +- perch/Features/Calendar/TodayEventsColumn.swift | 2 +- perch/Features/NowPlaying/NowPlayingLyricsColumn.swift | 4 ++-- perch/Island/IslandChromeStyle.swift | 8 ++++---- perch/Island/IslandHost.swift | 2 +- perch/UI/ExpandedIslandView.swift | 8 ++++---- .../{AtollStyleExpandedView.swift => RichHomeView.swift} | 2 +- perch/UI/SurfaceMetrics.swift | 4 ++-- perch/UI/SurfaceSizeResolver.swift | 2 +- perch/Vendor/NookSurface/NookStyle.swift | 4 ++-- 12 files changed, 22 insertions(+), 22 deletions(-) rename perch/UI/{AtollStyleExpandedView.swift => RichHomeView.swift} (99%) diff --git a/perch/Core/Preferences.swift b/perch/Core/Preferences.swift index c42bb77..1f762fe 100644 --- a/perch/Core/Preferences.swift +++ b/perch/Core/Preferences.swift @@ -22,7 +22,7 @@ extension Defaults.Keys { /// installs are migrated once at launch by `PreferencesMigration`. static let islandChromeStyle = Key("islandChromeStyle", default: .notch) - /// Rich (Atoll-inspired, default) vs Minimal (the original preset-driven widget list). + /// Rich (the fuller default layout) vs Minimal (the original preset-driven widget list). static let uiMode = Key("uiMode", default: .rich) /// Which display the island lives on. Resolved through `ScreenLocator` and handed to diff --git a/perch/Core/UIMode.swift b/perch/Core/UIMode.swift index 89276c9..c2a89fa 100644 --- a/perch/Core/UIMode.swift +++ b/perch/Core/UIMode.swift @@ -1,11 +1,11 @@ import Defaults import Foundation -/// Whether the expanded island shows the Atoll-inspired rich layout or the existing +/// Whether the expanded island shows the fuller rich layout or the existing /// preset-driven widget list. /// /// `.rich` is the default: a fixed layout (now-playing activity + calendar, see -/// `AtollStyleExpandedView`) that does not expose preset switching — there is nothing to +/// `RichHomeView`) that does not expose preset switching — there is nothing to /// switch between, since the layout is not preset-driven. `.minimal` keeps the original /// `PresetTabBar` + `WidgetRegistry`-driven experience for users who prefer it. enum UIMode: String, Codable, CaseIterable, Sendable, Defaults.Serializable, Defaults.PreferRawRepresentable { diff --git a/perch/Features/Calendar/CalendarStandaloneView.swift b/perch/Features/Calendar/CalendarStandaloneView.swift index 175e5d6..e139990 100644 --- a/perch/Features/Calendar/CalendarStandaloneView.swift +++ b/perch/Features/Calendar/CalendarStandaloneView.swift @@ -5,7 +5,7 @@ import SwiftUI /// docs/macOS-Expanded-Surface-Layout-Handbook-ja.md §13 ("Standalone Calendar"): a /// month grid (with day tap-to-select) on the left, the selected day's events on the /// right. Display-only — `store`'s fetch lifecycle belongs to the parent -/// (`AtollStyleExpandedView`), same pattern as `NowPlayingLyricsColumn`. +/// (`RichHomeView`), same pattern as `NowPlayingLyricsColumn`. /// /// Supersedes the old `CalendarMonthColumn` (hover-to-reveal grid, today-only /// highlight, no selection) — that shape only made sense as a narrow column sharing diff --git a/perch/Features/Calendar/TodayEventsColumn.swift b/perch/Features/Calendar/TodayEventsColumn.swift index da4c9ba..e7c26c4 100644 --- a/perch/Features/Calendar/TodayEventsColumn.swift +++ b/perch/Features/Calendar/TodayEventsColumn.swift @@ -3,7 +3,7 @@ import SwiftUI /// Rich mode's center column when there's no now-playing content to show (no music, or /// music with no lyrics): today's calendar events, or the calendar permission state. /// Display-only — `store`'s fetch lifecycle belongs to the parent -/// (`AtollStyleExpandedView`), same pattern as `NowPlayingLyricsColumn`. +/// (`RichHomeView`), same pattern as `NowPlayingLyricsColumn`. struct TodayEventsColumn: View { let store: CalendarStore diff --git a/perch/Features/NowPlaying/NowPlayingLyricsColumn.swift b/perch/Features/NowPlaying/NowPlayingLyricsColumn.swift index 94b3f97..7cae35e 100644 --- a/perch/Features/NowPlaying/NowPlayingLyricsColumn.swift +++ b/perch/Features/NowPlaying/NowPlayingLyricsColumn.swift @@ -2,10 +2,10 @@ import SwiftUI /// Rich mode's center column: complex multi-line lyrics, kept alongside the artwork/ /// controls column rather than the single-line afterthought other Dynamic Island style -/// apps show. Display-only — the caller (`AtollStyleExpandedView`) owns the fetch +/// apps show. Display-only — the caller (`RichHomeView`) owns the fetch /// lifecycle since this column's data outlives any one `NowPlayingCard` render. /// -/// Only ever shown while lyrics exist or are loading — `AtollStyleExpandedView` falls +/// Only ever shown while lyrics exist or are loading — `RichHomeView` falls /// back to `TodayEventsColumn` once loading finishes with nothing found, so there's no /// "no lyrics" state to render here. struct NowPlayingLyricsColumn: View { diff --git a/perch/Island/IslandChromeStyle.swift b/perch/Island/IslandChromeStyle.swift index 5d0fb50..dfa4b33 100644 --- a/perch/Island/IslandChromeStyle.swift +++ b/perch/Island/IslandChromeStyle.swift @@ -1,8 +1,8 @@ import Defaults import Foundation -/// How the Perch island chrome presents itself: notch-attached (the default, "Atoll -/// 風" pseudo-notch regardless of whether the Mac has a physical notch) or a floating +/// How the Perch island chrome presents itself: notch-attached (the default, a +/// pseudo-notch pill regardless of whether the Mac has a physical notch) or a floating /// pill below the menu bar. /// /// Deliberately two-valued, not three. The vendored `NookPresentation` offers an @@ -19,8 +19,8 @@ import Foundation enum IslandChromeStyle: String, Codable, CaseIterable, Sendable, Defaults.Serializable, Defaults.PreferRawRepresentable { - /// Pseudo-notch chrome (Atoll-style), used whether or not the display has a - /// physical notch. The default. + /// Pseudo-notch chrome (a floating pill styled to sit flush with the display top), + /// used whether or not the display has a physical notch. The default. case notch /// A free-floating rounded pill just below the menu bar. case floating diff --git a/perch/Island/IslandHost.swift b/perch/Island/IslandHost.swift index 448651d..1269f63 100644 --- a/perch/Island/IslandHost.swift +++ b/perch/Island/IslandHost.swift @@ -58,7 +58,7 @@ final class IslandHost { // on it. // // `compactTopCornerRadius`/`compactBottomCornerRadius` round the compact pill - // further than the vendored default (6, 14) — an Atoll-inspired look. A `/vfr` + // further than the vendored default (6, 14) — a rounder pill look. A `/vfr` // pass over the user's reference recording didn't yield frame-accurate spring // constants (the capture's resolution/compression obscured the shell's exact // timing), so these — and `DesignSystem.shellOpen`/`shellClose` below — are the diff --git a/perch/UI/ExpandedIslandView.swift b/perch/UI/ExpandedIslandView.swift index 81b78a0..b42cebd 100644 --- a/perch/UI/ExpandedIslandView.swift +++ b/perch/UI/ExpandedIslandView.swift @@ -17,7 +17,7 @@ struct ExpandedIslandView: View { VStack(spacing: 0) { IslandTopBar() // Preset switching is a Minimal-mode-only concept — Rich mode's Home module is - // a fixed layout (`AtollStyleExpandedView`), not preset-driven, so there is + // a fixed layout (`RichHomeView`), not preset-driven, so there is // nothing for a preset tab bar to switch between. if uiMode == .minimal, IslandModuleContent.showsPresetTabBar(for: appState.activeCard) { HStack { @@ -40,7 +40,7 @@ struct ExpandedIslandView: View { /// Rich mode's Home layout wants more room than Minimal mode's single-column widget /// stack or the AI Usage full-screen view, so only widen the floor when - /// `AtollStyleExpandedView` is actually what's rendering. + /// `RichHomeView` is actually what's rendering. private var minExpandedWidth: CGFloat { guard uiMode == .rich, IslandModuleContent.content(for: appState.activeCard) == .presetDriven else { return 420 } @@ -53,7 +53,7 @@ struct ExpandedIslandView: View { // MARK: - Module Content /// Routes to the active module's content. `.nowPlaying` (Home) renders - /// `AtollStyleExpandedView` in Rich mode or the active preset's widgets in Minimal + /// `RichHomeView` in Rich mode or the active preset's widgets in Minimal /// mode; `.aiUsage` bypasses both entirely and shows the full AI usage screen /// regardless of `uiMode` — it was never preset-driven to begin with. See /// `IslandModuleContent` for the pure mapping this switches on. @@ -62,7 +62,7 @@ struct ExpandedIslandView: View { switch IslandModuleContent.content(for: appState.activeCard) { case .presetDriven: if uiMode == .rich { - AtollStyleExpandedView() + RichHomeView() } else { presetContent } diff --git a/perch/UI/AtollStyleExpandedView.swift b/perch/UI/RichHomeView.swift similarity index 99% rename from perch/UI/AtollStyleExpandedView.swift rename to perch/UI/RichHomeView.swift index b4702bd..3acba88 100644 --- a/perch/UI/AtollStyleExpandedView.swift +++ b/perch/UI/RichHomeView.swift @@ -10,7 +10,7 @@ import SwiftUI /// /// Not preset-driven like Minimal mode's `presetContent` — this is a fixed layout, so /// there's nothing here for `PresetTabBar` to switch between (see `ExpandedIslandView`). -struct AtollStyleExpandedView: View { +struct RichHomeView: View { @Environment(AppState.self) private var appState @State private var contentVisible = false @State private var lyrics: [LyricsLine] = [] diff --git a/perch/UI/SurfaceMetrics.swift b/perch/UI/SurfaceMetrics.swift index 6f4384b..8a59035 100644 --- a/perch/UI/SurfaceMetrics.swift +++ b/perch/UI/SurfaceMetrics.swift @@ -5,9 +5,9 @@ import CoreGraphics /// numbers scattered across views are how a 540pt width and a 260/300pt width end up /// disagreeing with each other. nonisolated enum SurfaceMetrics { - /// The handbook's own basis (§0.1) is 640pt, sized for a fuller Atoll layout + /// The handbook's own basis (§0.1) is 640pt, sized for a fuller reference layout /// (Mirror included). Perch has no Mirror; 640 is kept anyway as the width that - /// visibly closes the gap the user flagged against Atoll's real screenshot. + /// visibly closes the gap the user flagged against the reference mockup. static let baseContentWidth: CGFloat = 640 static let minContentWidth: CGFloat = 480 static let maxContentWidthFloor: CGFloat = 400 diff --git a/perch/UI/SurfaceSizeResolver.swift b/perch/UI/SurfaceSizeResolver.swift index f58ce52..9714b9f 100644 --- a/perch/UI/SurfaceSizeResolver.swift +++ b/perch/UI/SurfaceSizeResolver.swift @@ -3,7 +3,7 @@ import CoreGraphics /// Resolves Rich mode's Home content target size from screen width, per /// docs/macOS-Expanded-Surface-Layout-Handbook-ja.md §4 ("Size Resolver") — one pure /// function both the window floor (`ExpandedIslandView`) and the content layout -/// (`AtollStyleExpandedView`) read, instead of each carrying its own hardcoded number. +/// (`RichHomeView`) read, instead of each carrying its own hardcoded number. /// /// `PageKind` has one case today because Rich mode has one page shape (Home). The /// handbook's Resolver switches on several page kinds (Timer, Notes, Terminal, ...); diff --git a/perch/Vendor/NookSurface/NookStyle.swift b/perch/Vendor/NookSurface/NookStyle.swift index ee6dbee..19c06b5 100644 --- a/perch/Vendor/NookSurface/NookStyle.swift +++ b/perch/Vendor/NookSurface/NookStyle.swift @@ -38,8 +38,8 @@ public struct NookEdgeInsets: Equatable, Sendable { /// bottom is the larger rounding where the panel meets the wallpaper. // Modified for Perch: added `compactTopCornerRadius`/`compactBottomCornerRadius` so a host // can tune the compact pill's roundedness. `NookView.compactCornerRadii` previously ignored -// this struct entirely and hardcoded (6, 14) — Perch's Atoll-inspired look needs a rounder -// pill than that default. +// this struct entirely and hardcoded (6, 14) — Perch's rounder pill look needs more +// rounding than that default. public struct NookStyle: Equatable, Sendable { public var topCornerRadius: CGFloat public var bottomCornerRadius: CGFloat From eed3b2979ecf728a93094b12a6f3234bced20923 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 12:49:41 +0900 Subject: [PATCH 03/44] fix: vertically center the lyrics/events column against the taller now-playing card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row's alignment was .top, but centerColumn is height-locked to SurfaceMetrics.lyricsColumnHeight (130pt) while NowPlayingCard runs 220-260pt — top-aligning pinned the lyrics box to the row's top edge with dead space below it. Changed to .center. Also gives the HStack an explicit idealWidth. The vendored NookView lays out the expanded surface under an unconstrained (nil) size proposal via .fixedSize(), so without an idealWidth the HStack's own ideal width was just NowPlayingCard's ideal plus the lyrics text's natural width — narrower than the 640pt window floor, so the outer frame(minWidth:) centered the whole narrow HStack as a block instead of letting centerColumn's maxWidth:.infinity actually fill the window. Co-Authored-By: Claude Opus 4.7 --- perch/UI/RichHomeView.swift | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/perch/UI/RichHomeView.swift b/perch/UI/RichHomeView.swift index 3acba88..a80557b 100644 --- a/perch/UI/RichHomeView.swift +++ b/perch/UI/RichHomeView.swift @@ -24,7 +24,12 @@ struct RichHomeView: View { var body: some View { Group { if let state = currentState { - HStack(alignment: .top, spacing: 0) { + // `alignment: .center` (not `.top`) — centerColumn is height-locked to + // SurfaceMetrics.lyricsColumnHeight (130pt) while NowPlayingCard's natural + // height runs 220-260pt; top-aligning left the lyrics box pinned to the row's + // top edge with dead space below it instead of sitting centered against the + // taller card next to it. + HStack(alignment: .center, spacing: 0) { NowPlayingCard(state: state, manager: appState.nowPlayingManager) .frame(minWidth: 260, idealWidth: 300, alignment: .leading) @@ -40,6 +45,18 @@ struct RichHomeView: View { .frame(maxWidth: .infinity, alignment: .center) .frame(height: SurfaceMetrics.lyricsColumnHeight) } + // The vendored NookView sizes the whole expanded surface via `.fixedSize()`, + // which lays out this subtree against an *unconstrained* (nil) proposal — + // not just a one-time size query. Without an explicit idealWidth here, this + // HStack's own ideal width under that nil proposal is just NowPlayingCard's + // ideal (300pt) plus the lyrics text's natural width, typically well under + // SurfaceMetrics.baseContentWidth (640pt); ExpandedIslandView's outer + // `.frame(minWidth: 640)` then centers that narrower HStack as a whole block + // inside the 640pt window, leaving centerColumn's `.frame(maxWidth: .infinity)` + // with nothing to actually expand into. Proposing 640 explicitly here makes + // the HStack lay out normally against a concrete width, so centerColumn + // receives the real remaining space instead of just its content's natural size. + .frame(idealWidth: SurfaceMetrics.baseContentWidth) } else { CalendarStandaloneView(store: calendarStore) } From bcc90c3c656d2099a6acebffea4062668886dd05 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 12:49:59 +0900 Subject: [PATCH 04/44] docs: check off B7-1 and B7-2 in progress.md Co-Authored-By: Claude Opus 4.7 --- docs/progress.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index d5e6c2d..0566a40 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -743,12 +743,12 @@ Phase B6完了後の実機フィードバックで、歌詞ボックスの垂直 アートワーク取得不安定の計6件の指摘があった。詳細な真因分析は `~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`のPhase B7セクション参照。 -- [ ] B7-1: 命名整理 — `AtollStyleExpandedView` → `RichHomeView`(ファイル名・型名・ +- [x] B7-1: 命名整理 — `AtollStyleExpandedView` → `RichHomeView`(ファイル名・型名・ 12ファイルのコメント参照)に機械的リネーム。過去のGit履歴は書き換えない - (AskUserQuestionでユーザーと合意済み) -- [ ] B7-2: 歌詞ボックスの縦方向センタリング — `RichHomeView.swift`のHStack + (AskUserQuestionでユーザーと合意済み)。コミット`77383b0`。 +- [x] B7-2: 歌詞ボックスの縦方向センタリング — `RichHomeView.swift`のHStack `alignment: .top` → `.center`。あわせて`.frame(idealWidth: SurfaceMetrics.baseContentWidth)` - 追加で横方向のfixedSize/nilプロポーザル起因の中央寄せ崩れも修正 + 追加で横方向のfixedSize/nilプロポーザル起因の中央寄せ崩れも修正。コミット`eed3b29`。 - [ ] B7-3: シークバーとアートワークの間隔調整 — `progressSection`に`.padding(.top, 8)`追加 - [ ] B7-4: 再生元バッジの黒円削除+アイコン拡大(18pt→40pt) - [ ] B7-5: Spotifyアートワークキャッシュの成功後書き込み化(`ArtworkFetcher.swift`) From d3aee25b1b68498a4b4c8d6d3ef35ab4d297667f Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 12:51:33 +0900 Subject: [PATCH 05/44] feat: add breathing room between the artwork row and the seek bar progressSection sat right below the artwork/trackInfo row with only the outer VStack's 10pt spacing between them, reading as too tight. Added 8pt of top padding on progressSection itself, leaving its own internal spacing and the gap to controlsSection unchanged. Co-Authored-By: Claude Opus 4.7 --- perch/Features/NowPlaying/NowPlayingCard.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/perch/Features/NowPlaying/NowPlayingCard.swift b/perch/Features/NowPlaying/NowPlayingCard.swift index 440166a..1618764 100644 --- a/perch/Features/NowPlaying/NowPlayingCard.swift +++ b/perch/Features/NowPlaying/NowPlayingCard.swift @@ -233,6 +233,7 @@ struct NowPlayingCard: View { } } } + .padding(.top, 8) } private var scrubElapsedLabel: String { From 70ce4184e05d641bc490355dde736d781562f30f Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 12:52:24 +0900 Subject: [PATCH 06/44] feat: drop the source badge's circular backdrop, enlarge the icon The black circle chip behind the source logo/symbol read as an unexplained decoration. Removed it and enlarged the icon from 18pt to 40pt (roughly a third of the 128pt artwork) so it reads clearly on its own, keeping the existing 0.3s delayed bounce-in. Co-Authored-By: Claude Opus 4.7 --- .../Features/NowPlaying/NowPlayingCard.swift | 46 +++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/perch/Features/NowPlaying/NowPlayingCard.swift b/perch/Features/NowPlaying/NowPlayingCard.swift index 1618764..716fffe 100644 --- a/perch/Features/NowPlaying/NowPlayingCard.swift +++ b/perch/Features/NowPlaying/NowPlayingCard.swift @@ -122,32 +122,30 @@ struct NowPlayingCard: View { /// card appears (handbook §11.2) so it doesn't compete with the artwork itself for /// attention the instant the card mounts. private var sourceBadge: some View { - Circle() - .fill(.black.opacity(0.7)) - .frame(width: 36, height: 36) - .overlay { - if let assetName = sourceLogoAssetName(state.source) { - Image(assetName) - .resizable() - .renderingMode(.original) - .aspectRatio(contentMode: .fit) - .frame(width: 18, height: 18) - } else { - Image(systemName: state.source.symbolName) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.white) - } + Group { + if let assetName = sourceLogoAssetName(state.source) { + Image(assetName) + .resizable() + .renderingMode(.original) + .aspectRatio(contentMode: .fit) + .frame(width: 40, height: 40) + } else { + Image(systemName: state.source.symbolName) + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 40, height: 40) } - .scaleEffect(sourceBadgeVisible ? 1 : 0.6) - .opacity(sourceBadgeVisible ? 1 : 0) - .accessibilityLabel(state.source.displayName) - .task(id: state.artworkID) { - sourceBadgeVisible = false - try? await Task.sleep(for: .milliseconds(300)) - withAnimation(.spring(response: 0.35, dampingFraction: 0.65)) { - sourceBadgeVisible = true - } + } + .scaleEffect(sourceBadgeVisible ? 1 : 0.6) + .opacity(sourceBadgeVisible ? 1 : 0) + .accessibilityLabel(state.source.displayName) + .task(id: state.artworkID) { + sourceBadgeVisible = false + try? await Task.sleep(for: .milliseconds(300)) + withAnimation(.spring(response: 0.35, dampingFraction: 0.65)) { + sourceBadgeVisible = true } + } } // MARK: - Track Info From f54f2508efc6b26021088052f2d9315a266eb90c Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 12:52:38 +0900 Subject: [PATCH 07/44] docs: check off B7-3 and B7-4 in progress.md Co-Authored-By: Claude Opus 4.7 --- docs/progress.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index 0566a40..6333dc9 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -749,8 +749,9 @@ Phase B6完了後の実機フィードバックで、歌詞ボックスの垂直 - [x] B7-2: 歌詞ボックスの縦方向センタリング — `RichHomeView.swift`のHStack `alignment: .top` → `.center`。あわせて`.frame(idealWidth: SurfaceMetrics.baseContentWidth)` 追加で横方向のfixedSize/nilプロポーザル起因の中央寄せ崩れも修正。コミット`eed3b29`。 -- [ ] B7-3: シークバーとアートワークの間隔調整 — `progressSection`に`.padding(.top, 8)`追加 -- [ ] B7-4: 再生元バッジの黒円削除+アイコン拡大(18pt→40pt) +- [x] B7-3: シークバーとアートワークの間隔調整 — `progressSection`に`.padding(.top, 8)`追加。 + コミット`d3aee25`。 +- [x] B7-4: 再生元バッジの黒円削除+アイコン拡大(18pt→40pt)。コミット`70ce418`。 - [ ] B7-5: Spotifyアートワークキャッシュの成功後書き込み化(`ArtworkFetcher.swift`) - [ ] B7-6: Apple Musicアートワークのバウンド付き再試行(`NowPlayingManager.swift`) From a20625133e718bf4f962bd361557c747a772b4b1 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 12:53:33 +0900 Subject: [PATCH 08/44] fix: don't permanently mark a Spotify track's artwork URL as tried on download failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lastSpotifyURL was written before the download attempt, so a single transient failure (network flakiness, DNS) permanently blocked retries for that track — the same URL would never pass the dedup guard again. Moved the write to after a successful download; NowPlayingManager's existing needsFetch check (state.artwork == nil) already retries on the next notification when it's missing. Co-Authored-By: Claude Opus 4.7 --- perch/Features/NowPlaying/ArtworkFetcher.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/perch/Features/NowPlaying/ArtworkFetcher.swift b/perch/Features/NowPlaying/ArtworkFetcher.swift index 8dcb37b..4c3b775 100644 --- a/perch/Features/NowPlaying/ArtworkFetcher.swift +++ b/perch/Features/NowPlaying/ArtworkFetcher.swift @@ -25,9 +25,12 @@ actor ArtworkFetcher { urlString != lastSpotifyURL, let url = URL(string: urlString) else { return nil } - lastSpotifyURL = urlString do { let (data, _) = try await URLSession.shared.data(from: url) + // Only recorded once the download actually succeeds — recording it earlier + // meant a single transient failure (e.g. startup network flakiness) marked the + // track as "tried" forever, since this same URL would never be attempted again. + lastSpotifyURL = urlString return data } catch { return nil From 1e7ba745d8509e67b8b5e30e2c195dcdd044c869 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 12:54:35 +0900 Subject: [PATCH 09/44] fix: retry Apple Music artwork fetch on the existing position poll com.apple.Music.playerInfo only fires on Play/Pause toggle or track change (no frequent position notifications like Spotify), so a single failed AppleScript artwork fetch right after a track change had no further chance to retry. Piggybacks on the existing 1.5s pollAppleMusicPosition() loop instead of adding a bespoke timer: retries up to 3 times (~4.5s window) per track when artwork is still nil, then gives up so a track that genuinely has none doesn't retry forever. Co-Authored-By: Claude Opus 4.7 --- .../NowPlaying/NowPlayingManager.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/perch/Features/NowPlaying/NowPlayingManager.swift b/perch/Features/NowPlaying/NowPlayingManager.swift index 2a9c1c7..f1eaf5d 100644 --- a/perch/Features/NowPlaying/NowPlayingManager.swift +++ b/perch/Features/NowPlaying/NowPlayingManager.swift @@ -47,6 +47,13 @@ final class NowPlayingManager { private nonisolated(unsafe) var mediaRemoteStateTask: Task? private var isYTMPolling: Bool = false private var wasYTMPolling: Bool = false + // Bounded retry for Apple Music artwork: `com.apple.Music.playerInfo` only fires on + // Play/Pause toggle or track change (unlike Spotify's frequent position notifications), + // so a single failed AppleScript fetch right after a track change (e.g. Music.app hasn't + // cached a streaming track's artwork locally yet) had no further chance to retry. These + // piggyback on the existing 1.5s position poll instead of a bespoke timer. + private var appleMusicArtworkRetryTrack: String = "" + private var appleMusicArtworkRetryCount: Int = 0 private let logger: Logger = { var logger = Logger(label: "com.tukuyomi032.perch.NowPlayingManager") logger.logLevel = .debug @@ -473,6 +480,17 @@ final class NowPlayingManager { isPlaying: current.isPlaying, duration: current.duration, elapsedTime: position, timestamp: Date(), source: current.source ) + + guard current.artwork == nil else { return } + if appleMusicArtworkRetryTrack != current.title { + appleMusicArtworkRetryTrack = current.title + appleMusicArtworkRetryCount = 0 + } + guard appleMusicArtworkRetryCount < 3 else { return } + appleMusicArtworkRetryCount += 1 + Task { [weak self] in + await self?.fetchAndApplyArtwork(for: current) + } } // MARK: - MRMediaRemote fallback From 80ac75ce9b6cfba5e62ff25f574c60f441508be9 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 12:54:50 +0900 Subject: [PATCH 10/44] docs: check off B7-5 and B7-6 in progress.md, close out Phase B7 Co-Authored-By: Claude Opus 4.7 --- docs/progress.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index 6333dc9..a30ed17 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -752,8 +752,15 @@ Phase B6完了後の実機フィードバックで、歌詞ボックスの垂直 - [x] B7-3: シークバーとアートワークの間隔調整 — `progressSection`に`.padding(.top, 8)`追加。 コミット`d3aee25`。 - [x] B7-4: 再生元バッジの黒円削除+アイコン拡大(18pt→40pt)。コミット`70ce418`。 -- [ ] B7-5: Spotifyアートワークキャッシュの成功後書き込み化(`ArtworkFetcher.swift`) -- [ ] B7-6: Apple Musicアートワークのバウンド付き再試行(`NowPlayingManager.swift`) +- [x] B7-5: Spotifyアートワークキャッシュの成功後書き込み化(`ArtworkFetcher.swift`)。 + コミット`a206251`。 +- [x] B7-6: Apple Musicアートワークのバウンド付き再試行(`NowPlayingManager.swift`)。 + `pollAppleMusicPosition()`の既存1.5秒間隔ポーリングに相乗りし上限3回まで再試行。 + コミット`1e7ba74`。 + +Phase B7は6タスク全完了。実機検証は次回実機起動時に通しで実施予定 +(歌詞ボックスの垂直/水平センタリング、シークバー間隔、バッジ、Spotify/Apple Music +アートワーク安定性を重点確認)。 ### Phase D: バッテリー監視・アニメーション本格実装(未着手・タスク分割のみ) From 393f94665b6e66d293ccdbf046cf0b8ce7cc699c Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 13:23:23 +0900 Subject: [PATCH 11/44] docs: add Phase B8 checklist for lyrics windowing, centering, header cleanup Co-Authored-By: Claude Opus 4.7 --- docs/progress.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/progress.md b/docs/progress.md index a30ed17..f483589 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -762,6 +762,26 @@ Phase B7は6タスク全完了。実機検証は次回実機起動時に通し (歌詞ボックスの垂直/水平センタリング、シークバー間隔、バッジ、Spotify/Apple Music アートワーク安定性を重点確認)。 +### Phase B8: 歌詞ウィンドウ表示化 + 垂直センタリング再修正 + ヘッダー整理(2026-07-31〜) + +Phase B7の実機検証で、歌詞ボックスの垂直中央がまだズレている(今度は下寄り、ヘッダー分の高さを +考慮していなかった)、歌詞前後の行が透けて見える(`.mask`によるフェードが実質縁取りのみで +opacityが0にならない設計だった)、シークバーがまだアートワークに近すぎる(非対称パディング)、 +再生元バッジ拡大に伴う曲名エリアとの近接、ヘッダーの閉じるボタンが不要、ModuleSwitcherの +アイコン間隔が広すぎる、の6件の指摘があった。詳細な真因分析は +`~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`のPhase B8セクション参照。 + +- [ ] B8-1: 歌詞ウィンドウ表示化 — `LyricsView.swift`をScrollView+mask方式から固定4行の + ウィンドウ表示に作り替え、前後行の透け見えを根治 +- [ ] B8-2: 歌詞ボックスの垂直センタリング再修正 — `SurfaceMetrics.headerHeight`定数を追加し + `RichHomeView.swift`のcenterColumnに`.offset(y: -headerHeight/2)`でヘッダー分を補正 +- [ ] B8-3: シークバーをartwork行とcontrolsSectionの中間に揃える — + `progressSection`の`.padding(.top, 8)` → `.padding(.vertical, 8)` +- [ ] B8-4: trackInfoを上にずらす+アルバム名の改行制限撤廃 — 上側Spacer削除、 + アルバムの`.lineLimit(2)`削除 +- [ ] B8-5: ヘッダーの閉じるボタン削除(`IslandTopBar.swift`) +- [ ] B8-6: ModuleSwitcherのアイコン間隔を詰める(spacing 24→8) + ### Phase D: バッテリー監視・アニメーション本格実装(未着手・タスク分割のみ) **参照**: `docs/macOS-Battery-Monitoring-Animation-Handbook-ja.md`(全39章、 From f6a45254ba0d2d2f752abc20c75d59187505a6db Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 13:24:17 +0900 Subject: [PATCH 12/44] fix: replace scrolling lyrics with a fixed 4-line window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old ScrollView + gradient mask never actually hid off-window lines — lineOpacity() floored at 0.20-0.65 rather than 0, and the mask only faded the outer 5-8% of the view, so lines beyond the intended 3-4 always showed through faintly. Replaced with a plain VStack rendering exactly the 4 lines in view (fewer if the song has less), computed from activeIndex and crossfaded when the window shifts — no lines exist outside that set, so there's nothing left to peek through. Co-Authored-By: Claude Opus 4.7 --- perch/Features/NowPlaying/LyricsView.swift | 86 ++++++++++------------ 1 file changed, 37 insertions(+), 49 deletions(-) diff --git a/perch/Features/NowPlaying/LyricsView.swift b/perch/Features/NowPlaying/LyricsView.swift index 69ecc7d..ed29944 100644 --- a/perch/Features/NowPlaying/LyricsView.swift +++ b/perch/Features/NowPlaying/LyricsView.swift @@ -6,6 +6,8 @@ struct LyricsView: View { let elapsedTime: TimeInterval var fontSize: CGFloat = 13 + private static let visibleLineCount = 4 + private var activeIndex: Int? { guard !lines.isEmpty else { return nil } for i in stride(from: lines.count - 1, through: 0, by: -1) { @@ -14,62 +16,48 @@ struct LyricsView: View { return nil } + /// Start of the fixed 4-line window shown on screen. A window, not a scroll + /// position, so the column only ever renders whole lines — no partial fade-in/out + /// line peeking past its edges the way a continuously-scrolled view would. Biases + /// the active line to the window's second slot (one line of context above, two + /// lines of what's coming below), clamping at both ends of `lines`. + private var windowStart: Int { + guard !lines.isEmpty else { return 0 } + let count = min(Self.visibleLineCount, lines.count) + let active = activeIndex ?? 0 + let desired = active - 1 + return max(0, min(desired, lines.count - count)) + } + + private var window: [(offset: Int, element: LyricsLine)] { + let count = min(Self.visibleLineCount, lines.count) + let start = windowStart + return Array(Array(lines.enumerated())[start..<(start + count)]) + } + var body: some View { - ScrollViewReader { proxy in - ScrollView(.vertical, showsIndicators: false) { - LazyVStack(alignment: .center, spacing: 10) { - Color.clear.frame(height: 6) - ForEach(Array(lines.enumerated()), id: \.element.id) { idx, line in - Text(line.text) - .font(.system(size: fontSize, weight: .regular)) - .foregroundStyle(.white.opacity(lineOpacity(idx))) - .scaleEffect(idx == activeIndex ? 1.13 : 1.0, anchor: .center) - .multilineTextAlignment(.center) - .lineLimit(2) - .frame(maxWidth: .infinity) - .animation(.spring(response: 0.40, dampingFraction: 0.82), value: activeIndex) - .id(line.id) - } - Color.clear.frame(height: 6) - } - .padding(.horizontal, 16) - .frame(maxWidth: .infinity) - } - .frame(maxWidth: .infinity) - .onChange(of: activeIndex) { _, newIdx in - guard let newIdx else { return } - withAnimation(.easeInOut(duration: 0.4)) { - proxy.scrollTo(lines[newIdx].id, anchor: UnitPoint(x: 0.5, y: 0.35)) - } - } - .onAppear { - guard let idx = activeIndex else { return } - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(100)) - proxy.scrollTo(lines[idx].id, anchor: UnitPoint(x: 0.5, y: 0.35)) - } + VStack(spacing: 10) { + ForEach(window, id: \.element.id) { idx, line in + Text(line.text) + .font(.system(size: fontSize, weight: .regular)) + .foregroundStyle(.white.opacity(lineOpacity(idx))) + .scaleEffect(idx == activeIndex ? 1.13 : 1.0, anchor: .center) + .multilineTextAlignment(.center) + .lineLimit(2) + .frame(maxWidth: .infinity) + .animation(.spring(response: 0.40, dampingFraction: 0.82), value: activeIndex) } } - .mask( - LinearGradient( - stops: [ - .init(color: .clear, location: 0), - .init(color: .black, location: 0.05), - .init(color: .black, location: 0.92), - .init(color: .clear, location: 1), - ], - startPoint: .top, - endPoint: .bottom - ) - ) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity) + .id(windowStart) + .transition(.opacity.combined(with: .move(edge: .bottom))) + .animation(.easeInOut(duration: 0.4), value: windowStart) } private func lineOpacity(_ idx: Int) -> Double { guard let active = activeIndex else { return 0.40 } if idx == active { return 1.0 } - let distance = abs(idx - active) - if distance == 1 { return 0.65 } - if distance == 2 { return 0.45 } - return max(0.20, 0.32 - Double(distance - 3) * 0.05) + return abs(idx - active) == 1 ? 0.65 : 0.40 } } From a4ed7e41877325d9ca2c6da2af980bca996a5941 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 13:25:11 +0900 Subject: [PATCH 13/44] fix: compensate lyrics column centering for the header sitting above the row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centering centerColumn against NowPlayingCard's own height ignores that IslandTopBar (44pt) plus its Divider sit above the whole row — the header's height visibly pulls the true center of the visible pill higher than NowPlayingCard's center alone, so lyrics rendered noticeably low relative to the full panel. Added SurfaceMetrics.headerHeight (an approximation derived from the header's own padding/control sizes) and offset centerColumn up by half of it. Co-Authored-By: Claude Opus 4.7 --- perch/UI/RichHomeView.swift | 6 ++++++ perch/UI/SurfaceMetrics.swift | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/perch/UI/RichHomeView.swift b/perch/UI/RichHomeView.swift index a80557b..8971bcc 100644 --- a/perch/UI/RichHomeView.swift +++ b/perch/UI/RichHomeView.swift @@ -44,6 +44,12 @@ struct RichHomeView: View { centerColumn .frame(maxWidth: .infinity, alignment: .center) .frame(height: SurfaceMetrics.lyricsColumnHeight) + // Centering above is against NowPlayingCard's own height, but + // IslandTopBar sits above this whole row — the header's height + // visibly pulls the true center of the visible pill higher than + // NowPlayingCard's center alone. Nudge up by half the header's + // height to compensate. + .offset(y: -SurfaceMetrics.headerHeight / 2) } // The vendored NookView sizes the whole expanded surface via `.fixedSize()`, // which lays out this subtree against an *unconstrained* (nil) proposal — diff --git a/perch/UI/SurfaceMetrics.swift b/perch/UI/SurfaceMetrics.swift index 8a59035..8a57376 100644 --- a/perch/UI/SurfaceMetrics.swift +++ b/perch/UI/SurfaceMetrics.swift @@ -27,4 +27,11 @@ nonisolated enum SurfaceMetrics { /// line spacing): `(13 * 1.3 + 10) * 4 ≈ 130pt`, where 13*1.3 approximates a single /// line's rendered height (font size × typical line-height multiplier). static let lyricsColumnHeight: CGFloat = 130 + + /// `IslandTopBar`'s own rendered height (10pt top padding + 26pt tallest control + + /// 8pt bottom padding = 44pt) plus the hairline `Divider` `ExpandedIslandView` draws + /// below it. An approximation from the source, not a measurement — used only to + /// compensate `RichHomeView`'s lyrics/events column centering for the header sitting + /// above the row it's actually centered against (see `RichHomeView`). + static let headerHeight: CGFloat = 45 } From 34f1e6089d041510c6c4a039b321db633f736aaf Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 13:26:28 +0900 Subject: [PATCH 14/44] fix: center the seek bar between the artwork row and controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix (.padding(.top, 8)) only widened the gap above progressSection, leaving it 18pt from the artwork row but only 10pt from controlsSection — still visibly closer to the controls than centered. Changed to .padding(.vertical, 8) so both gaps match at 18pt, putting the seek bar block midway between the two. Co-Authored-By: Claude Opus 4.7 --- perch/Features/NowPlaying/NowPlayingCard.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/perch/Features/NowPlaying/NowPlayingCard.swift b/perch/Features/NowPlaying/NowPlayingCard.swift index 716fffe..f918f32 100644 --- a/perch/Features/NowPlaying/NowPlayingCard.swift +++ b/perch/Features/NowPlaying/NowPlayingCard.swift @@ -231,7 +231,7 @@ struct NowPlayingCard: View { } } } - .padding(.top, 8) + .padding(.vertical, 8) } private var scrubElapsedLabel: String { From c261396ece1138cf68548f2efa9cac70cd0fef03 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 13:27:12 +0900 Subject: [PATCH 15/44] fix: shift track info up and let the album name wrap fully MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropped trackInfo's top Spacer so title/artist/album pin to the artwork's top edge instead of self-centering in the 128pt row — the enlarged source badge (Phase B7) sits closer to a vertically centered block than a top-aligned one. Also removed the album Text's lineLimit(2) so long album names wrap in full instead of truncating. Co-Authored-By: Claude Opus 4.7 --- perch/Features/NowPlaying/NowPlayingCard.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/perch/Features/NowPlaying/NowPlayingCard.swift b/perch/Features/NowPlaying/NowPlayingCard.swift index f918f32..61e8a93 100644 --- a/perch/Features/NowPlaying/NowPlayingCard.swift +++ b/perch/Features/NowPlaying/NowPlayingCard.swift @@ -152,7 +152,6 @@ struct NowPlayingCard: View { private var trackInfo: some View { VStack(alignment: .leading, spacing: 4) { - Spacer(minLength: 0) Text(state.title) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.white) @@ -165,7 +164,6 @@ struct NowPlayingCard: View { Text(album) .font(.system(size: 11, weight: .regular)) .foregroundStyle(.white.opacity(0.4)) - .lineLimit(2) } Spacer(minLength: 0) } From 89632df01da76d6b46562a71ab595749070ff15e Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 13:28:29 +0900 Subject: [PATCH 16/44] feat: remove the redundant close button from the expanded header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the cursor off the expanded surface already collapses it (the existing hover-exit behavior), so the explicit xmark button was redundant chrome. AppState.collapse() itself is untouched — kept as a public API other triggers may use, and still covered by AppStateTests. Co-Authored-By: Claude Opus 4.7 --- perch/UI/IslandTopBar.swift | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/perch/UI/IslandTopBar.swift b/perch/UI/IslandTopBar.swift index 4327298..f38e7f1 100644 --- a/perch/UI/IslandTopBar.swift +++ b/perch/UI/IslandTopBar.swift @@ -2,9 +2,9 @@ import SwiftUI /// The expanded island's top row: three zones per /// docs/macOS-Expanded-Surface-Layout-Handbook-ja.md §8 ("ヘッダーの3領域設計") — left -/// (close button + module switcher, `maxWidth: .infinity` leading), center (a reserved -/// gap the width of the physical/synthetic notch, so nothing renders under it), right -/// (`SystemStatusCluster`, `maxWidth: .infinity` trailing). +/// (module switcher, `maxWidth: .infinity` leading), center (a reserved gap the width of +/// the physical/synthetic notch, so nothing renders under it), right (`SystemStatusCluster`, +/// `maxWidth: .infinity` trailing). /// /// Modules reuse the existing `IslandCard` enum rather than a new type — `activeCard` /// was kept through Phase A specifically for this (see `AppState.activeCard`'s doc @@ -12,8 +12,6 @@ import SwiftUI /// are the only two buttons; `.fileShelf` / `.devStatus` / `.hud` stay unreachable until /// their features exist. struct IslandTopBar: View { - @Environment(AppState.self) private var appState - private static let modules: [IslandCard] = [.nowPlaying, .aiUsage] /// The vendored surface has no environment-exposed path to `Nook.notchSize` (only @@ -27,19 +25,11 @@ struct IslandTopBar: View { var body: some View { HStack(spacing: 0) { - HStack(spacing: 12) { - Button { - appState.collapse() - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.tertiary) - .imageScale(.medium) - } - .buttonStyle(.plain) - - ModuleSwitcher(modules: Self.modules) - } - .frame(maxWidth: .infinity, alignment: .leading) + // No explicit close button — moving the cursor off the expanded surface + // already collapses it (see `NookBridge`'s hover-exit handling), so a + // dedicated affordance here was redundant chrome. + ModuleSwitcher(modules: Self.modules) + .frame(maxWidth: .infinity, alignment: .leading) Color.clear .frame(width: notchReservationWidth) From 22a0de7865bf4cb1a082004e3b6951693d642f6c Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 13:29:02 +0900 Subject: [PATCH 17/44] feat: tighten spacing between Home/AI Usage header icons ModuleSwitcher's HStack spacing (24pt) plus each button's own 6pt horizontal padding read as an unnaturally wide gap between the two icons. Reduced spacing to 8pt. Co-Authored-By: Claude Opus 4.7 --- perch/UI/IslandTopBar.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/perch/UI/IslandTopBar.swift b/perch/UI/IslandTopBar.swift index f38e7f1..4713f5e 100644 --- a/perch/UI/IslandTopBar.swift +++ b/perch/UI/IslandTopBar.swift @@ -51,7 +51,7 @@ private struct ModuleSwitcher: View { @Namespace private var selectionNamespace var body: some View { - HStack(spacing: 24) { + HStack(spacing: 8) { ForEach(modules) { module in Button { withAnimation(DesignSystem.springAnimation) { From 0c5dec6ab70c98b780d5ef0b9e78093a0735d780 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 13:29:27 +0900 Subject: [PATCH 18/44] docs: check off Phase B8 tasks in progress.md Co-Authored-By: Claude Opus 4.7 --- docs/progress.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index f483589..b272f1d 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -771,16 +771,20 @@ opacityが0にならない設計だった)、シークバーがまだアート アイコン間隔が広すぎる、の6件の指摘があった。詳細な真因分析は `~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`のPhase B8セクション参照。 -- [ ] B8-1: 歌詞ウィンドウ表示化 — `LyricsView.swift`をScrollView+mask方式から固定4行の - ウィンドウ表示に作り替え、前後行の透け見えを根治 -- [ ] B8-2: 歌詞ボックスの垂直センタリング再修正 — `SurfaceMetrics.headerHeight`定数を追加し - `RichHomeView.swift`のcenterColumnに`.offset(y: -headerHeight/2)`でヘッダー分を補正 -- [ ] B8-3: シークバーをartwork行とcontrolsSectionの中間に揃える — - `progressSection`の`.padding(.top, 8)` → `.padding(.vertical, 8)` -- [ ] B8-4: trackInfoを上にずらす+アルバム名の改行制限撤廃 — 上側Spacer削除、 - アルバムの`.lineLimit(2)`削除 -- [ ] B8-5: ヘッダーの閉じるボタン削除(`IslandTopBar.swift`) -- [ ] B8-6: ModuleSwitcherのアイコン間隔を詰める(spacing 24→8) +- [x] B8-1: 歌詞ウィンドウ表示化 — `LyricsView.swift`をScrollView+mask方式から固定4行の + ウィンドウ表示に作り替え、前後行の透け見えを根治。コミット`f6a4525`。 +- [x] B8-2: 歌詞ボックスの垂直センタリング再修正 — `SurfaceMetrics.headerHeight`定数を追加し + `RichHomeView.swift`のcenterColumnに`.offset(y: -headerHeight/2)`でヘッダー分を補正。 + コミット`a4ed7e4`。 +- [x] B8-3: シークバーをartwork行とcontrolsSectionの中間に揃える — + `progressSection`の`.padding(.top, 8)` → `.padding(.vertical, 8)`。コミット`34f1e60`。 +- [x] B8-4: trackInfoを上にずらす+アルバム名の改行制限撤廃 — 上側Spacer削除、 + アルバムの`.lineLimit(2)`削除。コミット`c261396`。 +- [x] B8-5: ヘッダーの閉じるボタン削除(`IslandTopBar.swift`)。コミット`89632df`。 +- [x] B8-6: ModuleSwitcherのアイコン間隔を詰める(spacing 24→8)。コミット`22a0de7`。 + +Phase B8は6タスク全完了。実機検証は次回実機起動時に通しで実施予定 +(歌詞の垂直位置・4行固定表示・シークバー位置・trackInfo位置・ヘッダーの見た目を重点確認)。 ### Phase D: バッテリー監視・アニメーション本格実装(未着手・タスク分割のみ) From eff58c912cf1e3341e10e4e33855bc796f969d75 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 14:04:37 +0900 Subject: [PATCH 19/44] docs: add Phase B9 checklist for CFBundleIdentifier fix and layout tweaks Co-Authored-By: Claude Opus 4.7 --- docs/progress.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/progress.md b/docs/progress.md index b272f1d..489a3ce 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -786,6 +786,23 @@ opacityが0にならない設計だった)、シークバーがまだアート Phase B8は6タスク全完了。実機検証は次回実機起動時に通しで実施予定 (歌詞の垂直位置・4行固定表示・シークバー位置・trackInfo位置・ヘッダーの見た目を重点確認)。 +### Phase B9: CFBundleIdentifier欠落修正 + trackInfo位置微調整 + 歌詞遷移速度調整(2026-07-31〜) + +Phase B8実機検証で「`just run`起動だとアートワーク取得・コントロールボタンが効かないが、 +Command+Space直接起動だとどちらも動く」という不可解な報告があった。調査の結果、 +`perch/Resources/Info.plist`に`CFBundleIdentifier`が定義されておらず、ビルドごとに異なる +仮識別子・署名アイデンティティが生成されていたことが判明——macOSのTCC(Automation権限)は +署名アイデンティティ単位でも権限を区別するため、`/Applications`のad-hoc版と`run.sh`が起動する +開発証明書版が別アプリとして扱われ、片方にしかSpotify/Music操作の許可が付与されていなかった。 +詳細は`~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`のPhase B9セクション参照。 + +- [ ] B9-1: `perch/Resources/Info.plist`に`CFBundleIdentifier` + (`$(PRODUCT_BUNDLE_IDENTIFIER)`)を追加——起動方法によるアートワーク/コントロール + 不整合の根治 +- [ ] B9-2: trackInfoの垂直位置を10pt下げる(Phase B8で上に寄せすぎた) +- [ ] B9-3: 歌詞のアクティブ行切り替え・ウィンドウ遷移アニメーションを緩やかにする + (spring response 0.40→0.55、easeInOut duration 0.4→0.55) + ### Phase D: バッテリー監視・アニメーション本格実装(未着手・タスク分割のみ) **参照**: `docs/macOS-Battery-Monitoring-Animation-Handbook-ja.md`(全39章、 From 9163764cad2ed98df3c3f8501e1ace7d60eaabd6 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 14:05:52 +0900 Subject: [PATCH 20/44] fix: add the missing CFBundleIdentifier to Info.plist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom Info.plist never referenced PRODUCT_BUNDLE_IDENTIFIER, so every build shipped with no CFBundleIdentifier at all — codesign synthesized a different fallback identifier per build environment (confirmed: `just run`'s DerivedData build signed as "perch" with a Development cert/Team ID, while /Applications/perch.app carried an ad-hoc-signed hashed identifier with no team). macOS's Automation TCC grants are scoped per signing identity, so the two were tracked as separate apps — Automation permission granted to one (enabling artwork fetch and playback controls, both AppleScript-driven) simply didn't apply to the other. Verified the built app now reports Identifier=com.tukuyomi032.perch via codesign. Co-Authored-By: Claude Opus 4.7 --- perch/Resources/Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/perch/Resources/Info.plist b/perch/Resources/Info.plist index fb34f94..d6a3e70 100644 --- a/perch/Resources/Info.plist +++ b/perch/Resources/Info.plist @@ -2,6 +2,8 @@ + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion From ec71b55a2f820b43a2d9406372b4159773660cb9 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 14:07:08 +0900 Subject: [PATCH 21/44] fix: nudge track info down 10pt from the artwork's top edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B8 pinned title/artist/album flush to the artwork's top edge to put distance between them and the enlarged source badge — too far up per follow-up feedback. Added 10pt of top padding as a middle ground, still clearly separated from the badge but not pinned to the very top. Co-Authored-By: Claude Opus 4.7 --- perch/Features/NowPlaying/NowPlayingCard.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/perch/Features/NowPlaying/NowPlayingCard.swift b/perch/Features/NowPlaying/NowPlayingCard.swift index 61e8a93..656750e 100644 --- a/perch/Features/NowPlaying/NowPlayingCard.swift +++ b/perch/Features/NowPlaying/NowPlayingCard.swift @@ -167,6 +167,7 @@ struct NowPlayingCard: View { } Spacer(minLength: 0) } + .padding(.top, 10) } // MARK: - Progress From ea8afe35b50346f823e194eac525149d5b0312c1 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 14:08:00 +0900 Subject: [PATCH 22/44] fix: slow down lyrics active-line and window-swap animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Active-line emphasis (spring response 0.40) and window crossfade (easeInOut 0.4s) both read as too abrupt per feedback. Bumped to 0.55 for each — timestamp-driven timing (activeIndex) is unchanged, only how the transition itself moves. Co-Authored-By: Claude Opus 4.7 --- perch/Features/NowPlaying/LyricsView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/perch/Features/NowPlaying/LyricsView.swift b/perch/Features/NowPlaying/LyricsView.swift index ed29944..855ddcc 100644 --- a/perch/Features/NowPlaying/LyricsView.swift +++ b/perch/Features/NowPlaying/LyricsView.swift @@ -45,14 +45,14 @@ struct LyricsView: View { .multilineTextAlignment(.center) .lineLimit(2) .frame(maxWidth: .infinity) - .animation(.spring(response: 0.40, dampingFraction: 0.82), value: activeIndex) + .animation(.spring(response: 0.55, dampingFraction: 0.82), value: activeIndex) } } .padding(.horizontal, 16) .frame(maxWidth: .infinity) .id(windowStart) .transition(.opacity.combined(with: .move(edge: .bottom))) - .animation(.easeInOut(duration: 0.4), value: windowStart) + .animation(.easeInOut(duration: 0.55), value: windowStart) } private func lineOpacity(_ idx: Int) -> Double { From 15499f4b4f3ca892386dea63b262f09b409861ee Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 14:08:17 +0900 Subject: [PATCH 23/44] docs: check off Phase B9 tasks in progress.md Co-Authored-By: Claude Opus 4.7 --- docs/progress.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index 489a3ce..ecc068a 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -796,12 +796,16 @@ Command+Space直接起動だとどちらも動く」という不可解な報告 開発証明書版が別アプリとして扱われ、片方にしかSpotify/Music操作の許可が付与されていなかった。 詳細は`~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`のPhase B9セクション参照。 -- [ ] B9-1: `perch/Resources/Info.plist`に`CFBundleIdentifier` +- [x] B9-1: `perch/Resources/Info.plist`に`CFBundleIdentifier` (`$(PRODUCT_BUNDLE_IDENTIFIER)`)を追加——起動方法によるアートワーク/コントロール - 不整合の根治 -- [ ] B9-2: trackInfoの垂直位置を10pt下げる(Phase B8で上に寄せすぎた) -- [ ] B9-3: 歌詞のアクティブ行切り替え・ウィンドウ遷移アニメーションを緩やかにする - (spring response 0.40→0.55、easeInOut duration 0.4→0.55) + 不整合の根治。実機で`Identifier=com.tukuyomi032.perch`への反映を確認済み。 + コミット`9163764`。 +- [x] B9-2: trackInfoの垂直位置を10pt下げる(Phase B8で上に寄せすぎた)。コミット`ec71b55`。 +- [x] B9-3: 歌詞のアクティブ行切り替え・ウィンドウ遷移アニメーションを緩やかにする + (spring response 0.40→0.55、easeInOut duration 0.4→0.55)。コミット`ea8afe3`。 + +Phase B9は3タスク全完了。B9-1は過去に断片化したTCC Automation権限までは自動修復しないため、 +実機検証時に再度の許可ダイアログが出る可能性がある旨をユーザーに案内する必要あり。 ### Phase D: バッテリー監視・アニメーション本格実装(未着手・タスク分割のみ) From 04e4946714101b791235c95b1730891a98eb89fe Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 14:26:56 +0900 Subject: [PATCH 24/44] docs: add Phase B10 checklist for Automation permission fix and diagnostics Co-Authored-By: Claude Opus 4.7 --- docs/progress.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/progress.md b/docs/progress.md index ecc068a..44c1c21 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -807,6 +807,22 @@ Command+Space直接起動だとどちらも動く」という不可解な報告 Phase B9は3タスク全完了。B9-1は過去に断片化したTCC Automation権限までは自動修復しないため、 実機検証時に再度の許可ダイアログが出る可能性がある旨をユーザーに案内する必要あり。 +### Phase B10: Automation権限プロンプト欠落修正 + AppleScript失敗の診断強化 + 歌詞左寄せ(2026-07-31〜) + +Phase B9でCFBundleIdentifierを修正した後、`just run`・直接起動どちらでもアートワーク取得・ +再生コントロールが全く動作しなくなった。調査の結果、`perch/Resources/Info.plist`に +`NSAppleEventsUsageDescription`が無く、macOSがAutomation権限プロンプト自体を表示できず +AppleScript呼び出しが即座に権限拒否(-1743)で失敗する状態だったことが判明。加えて +AppleScript失敗時のログが一切無く原因切り分けができなかったため診断ログを追加する。 +歌詞テキストの水平配置も中央揃え→左寄せへの変更依頼があった。詳細は +`~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`のPhase B10セクション参照。 + +- [ ] B10-1: `NSAppleEventsUsageDescription`をInfo.plistに追加 + (Automation権限プロンプトが表示されない根本原因の修正) +- [ ] B10-2: `NowPlayingManager.runAppleScript`にエラー診断ログを追加 +- [ ] B10-3: `ArtworkFetcher`に`Logger`を追加し各AppleScript失敗パスにログを追加 +- [ ] B10-4: 歌詞テキストを中央揃え→左寄せに変更(`LyricsView.swift`) + ### Phase D: バッテリー監視・アニメーション本格実装(未着手・タスク分割のみ) **参照**: `docs/macOS-Battery-Monitoring-Animation-Handbook-ja.md`(全39章、 From 155b74b5d795c599eb0610850e7bd66dfe002dcc Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 14:28:09 +0900 Subject: [PATCH 25/44] fix: add the missing NSAppleEventsUsageDescription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without a usage description string, macOS never shows the Automation permission prompt for AppleScript control of Spotify/Music — it just fails silently (errAEEventNotPermitted, -1743) on the first attempt. This combined with the Phase B9 CFBundleIdentifier fix (which makes the app a genuinely new TCC identity requiring fresh consent) left artwork fetching and playback controls broken on every launch path, since there was no way to ever get prompted for permission. Verified the string now appears in the built app's Info.plist. Co-Authored-By: Claude Opus 4.7 --- perch/Resources/Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/perch/Resources/Info.plist b/perch/Resources/Info.plist index d6a3e70..e3a9220 100644 --- a/perch/Resources/Info.plist +++ b/perch/Resources/Info.plist @@ -20,6 +20,8 @@ l6IhQF6lojaWbwcObjcaHKIHC9LMuEvWDkh5dTLbmz4= NSCalendarsFullAccessUsageDescription Perch shows today's events in its expanded calendar widget. + NSAppleEventsUsageDescription + Perch controls Spotify and Music to show playback status and artwork, and to respond to its own play/pause and skip buttons. NSAppTransportSecurity NSExceptionDomains From d8f1b2176aeccef0c7b48edfb3e2dd138e263b19 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 14:29:12 +0900 Subject: [PATCH 26/44] feat: log AppleScript failures in NowPlayingManager.runAppleScript Previously swallowed errors silently, making it impossible to tell a permission denial (-1743) from any other failure when playback controls or artwork stopped working. Logs the NSAppleScript error number and message at debug level. Co-Authored-By: Claude Opus 4.7 --- perch/Features/NowPlaying/NowPlayingManager.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/perch/Features/NowPlaying/NowPlayingManager.swift b/perch/Features/NowPlaying/NowPlayingManager.swift index f1eaf5d..90f92c9 100644 --- a/perch/Features/NowPlaying/NowPlayingManager.swift +++ b/perch/Features/NowPlaying/NowPlayingManager.swift @@ -425,11 +425,22 @@ final class NowPlayingManager { } private func runAppleScript(_ source: String) async -> String? { - await Task.detached { + let logger = logger + return await Task.detached { var error: NSDictionary? let script = NSAppleScript(source: source) let result = script?.executeAndReturnError(&error) - guard error == nil else { return nil } + if let error { + // -1743 (errAEEventNotPermitted) means Automation permission for this + // target app hasn't been granted — logged distinctly from other failures + // so a silent playback-control/artwork outage is diagnosable from Console + // instead of just "nothing happens". + let code = error[NSAppleScript.errorNumber] as? Int ?? 0 + logger.debug( + "runAppleScript failed [\(code)]: \(error[NSAppleScript.errorMessage] as? String ?? "unknown")" + ) + return nil + } return result?.stringValue }.value } From 3f9efe8445f68880cb52aba5d65020ac6761ed78 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 14:30:26 +0900 Subject: [PATCH 27/44] feat: log AppleScript failures in ArtworkFetcher Added a Logger (matching NowPlayingManager's pattern) and logging on both AppleScript failure paths (fetchAppleMusicArtworkData, the shared runAppleScript used by Spotify's URL fetch), distinguishing permission denial (-1743) from other AppleScript errors and from a track that simply has no artwork. Co-Authored-By: Claude Opus 4.7 --- .../Features/NowPlaying/ArtworkFetcher.swift | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/perch/Features/NowPlaying/ArtworkFetcher.swift b/perch/Features/NowPlaying/ArtworkFetcher.swift index 4c3b775..7b8e719 100644 --- a/perch/Features/NowPlaying/ArtworkFetcher.swift +++ b/perch/Features/NowPlaying/ArtworkFetcher.swift @@ -1,6 +1,7 @@ // perch/Features/NowPlaying/ArtworkFetcher.swift @preconcurrency import AppKit import Foundation +import Logging /// Fetches album artwork via AppleScript for Spotify and Apple Music. /// `actor` isolation serializes fetches so NSAppleScript is never called concurrently. @@ -9,6 +10,11 @@ actor ArtworkFetcher { private init() {} private var lastSpotifyURL: String = "" + private let logger: Logger = { + var logger = Logger(label: "com.tukuyomi032.perch.ArtworkFetcher") + logger.logLevel = .debug + return logger + }() // MARK: - Spotify @@ -41,7 +47,8 @@ actor ArtworkFetcher { /// Returns artwork data by reading binary image data directly from the AppleScript descriptor. func fetchAppleMusicArtworkData() async -> Data? { - await Task.detached { + let logger = logger + return await Task.detached { var error: NSDictionary? let script = NSAppleScript( source: """ @@ -52,9 +59,21 @@ actor ArtworkFetcher { return art end tell """) - guard let result = script?.executeAndReturnError(&error), error == nil else { return nil } - let data = result.data - guard !data.isEmpty else { return nil } + let result = script?.executeAndReturnError(&error) + if let error { + // -1743 (errAEEventNotPermitted) means Automation permission for Music.app + // hasn't been granted — logged distinctly so a track that simply has no + // artwork isn't confused with a permission outage affecting every track. + let code = error[NSAppleScript.errorNumber] as? Int ?? 0 + logger.debug( + "fetchAppleMusicArtworkData failed [\(code)]: \(error[NSAppleScript.errorMessage] as? String ?? "unknown")" + ) + return nil + } + guard let data = result?.data, !data.isEmpty else { + logger.debug("fetchAppleMusicArtworkData: no artwork data on current track") + return nil + } return data }.value } @@ -102,11 +121,18 @@ actor ArtworkFetcher { // MARK: - Private private func runAppleScript(_ source: String) async -> String? { - await Task.detached { + let logger = logger + return await Task.detached { var error: NSDictionary? let script = NSAppleScript(source: source) let result = script?.executeAndReturnError(&error) - guard error == nil else { return nil } + if let error { + let code = error[NSAppleScript.errorNumber] as? Int ?? 0 + logger.debug( + "runAppleScript failed [\(code)]: \(error[NSAppleScript.errorMessage] as? String ?? "unknown")" + ) + return nil + } return result?.stringValue }.value } From a78aabd1ff512240455b0a27b74116f237df42a7 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 14:31:48 +0900 Subject: [PATCH 28/44] feat: left-align lyrics text instead of centering it Switched the VStack, multilineTextAlignment, and each line's frame alignment from center to leading per feedback. Co-Authored-By: Claude Opus 4.7 --- perch/Features/NowPlaying/LyricsView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/perch/Features/NowPlaying/LyricsView.swift b/perch/Features/NowPlaying/LyricsView.swift index 855ddcc..12717ce 100644 --- a/perch/Features/NowPlaying/LyricsView.swift +++ b/perch/Features/NowPlaying/LyricsView.swift @@ -36,15 +36,15 @@ struct LyricsView: View { } var body: some View { - VStack(spacing: 10) { + VStack(alignment: .leading, spacing: 10) { ForEach(window, id: \.element.id) { idx, line in Text(line.text) .font(.system(size: fontSize, weight: .regular)) .foregroundStyle(.white.opacity(lineOpacity(idx))) .scaleEffect(idx == activeIndex ? 1.13 : 1.0, anchor: .center) - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) .lineLimit(2) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, alignment: .leading) .animation(.spring(response: 0.55, dampingFraction: 0.82), value: activeIndex) } } From 6f2c42cd0849aa7a1a63ba5f99987b698d487ab2 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 14:32:06 +0900 Subject: [PATCH 29/44] docs: check off Phase B10 tasks in progress.md Co-Authored-By: Claude Opus 4.7 --- docs/progress.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index 44c1c21..668eec4 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -817,11 +817,16 @@ AppleScript失敗時のログが一切無く原因切り分けができなかっ 歌詞テキストの水平配置も中央揃え→左寄せへの変更依頼があった。詳細は `~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`のPhase B10セクション参照。 -- [ ] B10-1: `NSAppleEventsUsageDescription`をInfo.plistに追加 - (Automation権限プロンプトが表示されない根本原因の修正) -- [ ] B10-2: `NowPlayingManager.runAppleScript`にエラー診断ログを追加 -- [ ] B10-3: `ArtworkFetcher`に`Logger`を追加し各AppleScript失敗パスにログを追加 -- [ ] B10-4: 歌詞テキストを中央揃え→左寄せに変更(`LyricsView.swift`) +- [x] B10-1: `NSAppleEventsUsageDescription`をInfo.plistに追加 + (Automation権限プロンプトが表示されない根本原因の修正)。実機ビルドで反映確認済み。 + コミット`155b74b`。 +- [x] B10-2: `NowPlayingManager.runAppleScript`にエラー診断ログを追加。コミット`d8f1b21`。 +- [x] B10-3: `ArtworkFetcher`に`Logger`を追加し各AppleScript失敗パスにログを追加。 + コミット`3f9efe8`。 +- [x] B10-4: 歌詞テキストを中央揃え→左寄せに変更(`LyricsView.swift`)。コミット`a78aabd`。 + +Phase B10は4タスク全完了。B10-1は実機で許可ダイアログが表示され、許可後にアートワーク取得・ +コントロールボタンが動作することの確認が必要。 ### Phase D: バッテリー監視・アニメーション本格実装(未着手・タスク分割のみ) From 6a5ed031f3a41b52a8ee92e334bce2f5ece29f4c Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 14:46:29 +0900 Subject: [PATCH 30/44] docs: add Phase B11 checklist for Settings Permissions tab Co-Authored-By: Claude Opus 4.7 --- docs/progress.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/progress.md b/docs/progress.md index 668eec4..dfd0487 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -828,6 +828,24 @@ AppleScript失敗時のログが一切無く原因切り分けができなかっ Phase B10は4タスク全完了。B10-1は実機で許可ダイアログが表示され、許可後にアートワーク取得・ コントロールボタンが動作することの確認が必要。 +### Phase B11: 設定画面「権限」タブ新設(2026-07-31〜) + +Phase B10の`NSAppleEventsUsageDescription`追加後も実機でAutomation権限エラー(-1743)が +継続。TCCの既知の挙動(コード署名が変わると過去の許可/拒否記録が新しいビルドに引き継がれず、 +かつOSが自動で再プロンプトを出さない)が原因と判明。AskUserQuestionで相談の結果、設定画面に +「権限」タブを新設し、Calendar/Screen Recording/Automation(Spotify・Apple Music)の状態確認・ +付与を行えるようにする方針で合意。詳細は +`~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`のPhase B11セクション参照。 + +**OSレベルの制約(コードでは解決不可)**: 既に拒否済みの権限はアプリから再プロンプトを強制 +できない。ユーザーには本タスク実装を待たずにシステム設定でMusicトグルをオフ→オン、または +`tccutil reset AppleEvents com.tukuyomi032.perch`実行後の再起動を案内済み。 + +- [ ] B11-1: `PermissionsStore`新規実装(Calendar/Screen Recording/Automation×2の状態確認・ + 付与ロジック) +- [ ] B11-2: `SettingsView`に`PermissionsTab`追加 +- [ ] B11-3: ローカライズ文字列追加(項目名・ボタン文言) + ### Phase D: バッテリー監視・アニメーション本格実装(未着手・タスク分割のみ) **参照**: `docs/macOS-Battery-Monitoring-Animation-Handbook-ja.md`(全39章、 From a039945f4bf4df69ea89b6c81eb4e968c6c6d451 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 14:48:35 +0900 Subject: [PATCH 31/44] feat: add PermissionsStore for Calendar/Screen Recording/Automation status New @Observable store following CalendarStore's authorization-state pattern. refreshAll() preflight-checks all four permissions without ever prompting (AEDeterminePermissionToAutomateTarget with askUserIfNeeded: false for Automation, CGPreflightScreenCaptureAccess for Screen Recording, EKEventStore.authorizationStatus for Calendar). request(_:) triggers the real OS prompt when a permission is still notDetermined; once denied, macOS won't show another system dialog no matter how many times the app asks, so request(_:) opens the relevant System Settings pane instead. Co-Authored-By: Claude Opus 4.7 --- .../Permissions/PermissionsStore.swift | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 perch/Features/Permissions/PermissionsStore.swift diff --git a/perch/Features/Permissions/PermissionsStore.swift b/perch/Features/Permissions/PermissionsStore.swift new file mode 100644 index 0000000..649522c --- /dev/null +++ b/perch/Features/Permissions/PermissionsStore.swift @@ -0,0 +1,139 @@ +import AppKit +import EventKit +import ScreenCaptureKit + +/// Holds and refreshes the state of every permission Perch's features depend on, for the +/// Settings "Permissions" tab. Each check is preflight-only (no OS prompt) so opening the +/// tab never itself triggers a dialog — `request(_:)` is the only thing that can prompt, +/// and only when the OS is actually able to (see `PermissionStatus.denied`'s doc comment). +@MainActor +@Observable +final class PermissionsStore { + enum PermissionStatus: Equatable { + /// Granted. + case authorized + /// Never asked yet — `request(_:)` can trigger a real OS prompt. + case notDetermined + /// Already answered "no" once. macOS will not show another system prompt for + /// this identity no matter how many times the app asks — the only way forward + /// is System Settings, which `request(_:)` opens directly to the right pane. + case denied + /// Automation only: the target app isn't running, so `AEDeterminePermissionToAutomateTarget` + /// can't determine a real status without launching it first. + case unknown + } + + enum Kind: String, CaseIterable, Identifiable { + case calendar + case screenRecording + case automationSpotify + case automationAppleMusic + + var id: String { rawValue } + + var displayName: String { + switch self { + case .calendar: L10n.string("settings.permissions.calendar") + case .screenRecording: L10n.string("settings.permissions.screen_recording") + case .automationSpotify: L10n.string("settings.permissions.automation_spotify") + case .automationAppleMusic: L10n.string("settings.permissions.automation_apple_music") + } + } + } + + private(set) var statuses: [Kind: PermissionStatus] = [ + .calendar: .notDetermined, + .screenRecording: .notDetermined, + .automationSpotify: .notDetermined, + .automationAppleMusic: .notDetermined, + ] + + private static let spotifyBundleID = "com.spotify.client" + private static let appleMusicBundleID = "com.apple.Music" + + /// Preflight-checks every permission without prompting for any of them. Safe to call + /// whenever the Permissions tab appears — e.g. `.onAppear` — to pick up changes made + /// in System Settings while Perch was already running. + func refreshAll() { + statuses[.calendar] = Self.mapCalendarStatus(EKEventStore.authorizationStatus(for: .event)) + statuses[.screenRecording] = CGPreflightScreenCaptureAccess() ? .authorized : .denied + statuses[.automationSpotify] = Self.automationStatus(for: Self.spotifyBundleID) + statuses[.automationAppleMusic] = Self.automationStatus(for: Self.appleMusicBundleID) + } + + /// Requests the given permission. If it's already `.denied`, no OS prompt is possible + /// any more — opens the relevant System Settings pane instead. Otherwise triggers the + /// real permission flow and updates `statuses` with the result. + func request(_ kind: Kind) async { + switch kind { + case .calendar: + guard statuses[.calendar] != .denied else { + Self.openSystemSettings(pane: "Privacy_Calendars") + return + } + let granted = (try? await EKEventStore().requestFullAccessToEvents()) ?? false + statuses[.calendar] = granted ? .authorized : .denied + + case .screenRecording: + guard statuses[.screenRecording] != .denied else { + Self.openSystemSettings(pane: "Privacy_ScreenCapture") + return + } + statuses[.screenRecording] = CGRequestScreenCaptureAccess() ? .authorized : .denied + + case .automationSpotify: + await requestAutomation(bundleID: Self.spotifyBundleID, kind: .automationSpotify) + + case .automationAppleMusic: + await requestAutomation(bundleID: Self.appleMusicBundleID, kind: .automationAppleMusic) + } + } + + private func requestAutomation(bundleID: String, kind: Kind) async { + guard statuses[kind] != .denied else { + Self.openSystemSettings(pane: "Privacy_Automation") + return + } + // Off the main actor: AEDeterminePermissionToAutomateTarget with askUserIfNeeded + // blocks on the system prompt. + let status = await Task.detached { + Self.automationStatus(for: bundleID, askUserIfNeeded: true) + }.value + statuses[kind] = status + } + + private static func mapCalendarStatus(_ status: EKAuthorizationStatus) -> PermissionStatus { + switch status { + case .notDetermined: .notDetermined + case .fullAccess: .authorized + case .restricted, .denied, .writeOnly: .denied + @unknown default: .denied + } + } + + /// `askUserIfNeeded: false` (the default, used by `refreshAll`) never shows a system + /// prompt — it only reads the current TCC record. Passing `true` (used by `request(_:)`) + /// lets the OS show its permission dialog when the status is genuinely undetermined. + nonisolated private static func automationStatus( + for bundleID: String, askUserIfNeeded: Bool = false + ) -> PermissionStatus { + guard var target = NSAppleEventDescriptor(bundleIdentifier: bundleID).aeDesc?.pointee else { + return .unknown + } + let result = AEDeterminePermissionToAutomateTarget( + &target, typeWildCard, typeWildCard, askUserIfNeeded) + switch result { + case noErr: return .authorized + case -1743: return .denied // errAEEventNotPermitted + case -1744: return .notDetermined // errAEEventWouldRequireUserConsent + case -600: return .unknown // procNotFound — target app isn't running + default: return .unknown + } + } + + private static func openSystemSettings(pane: String) { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") + else { return } + NSWorkspace.shared.open(url) + } +} From 364997c486d80abcbb1fdfc6c6cbff4495a3c1c6 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 14:49:26 +0900 Subject: [PATCH 32/44] feat: add a Permissions tab to Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lists Calendar, Screen Recording, and Automation (Spotify/Apple Music) with a live status (color-coded) and a per-row action button — "request" when not yet determined, "open System Settings" once denied (macOS won't show another system prompt for a denied permission, so that's the only remaining path). Status refreshes via PermissionsStore.refreshAll() on .onAppear. Co-Authored-By: Claude Opus 4.7 --- perch/UI/SettingsView.swift | 62 +++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/perch/UI/SettingsView.swift b/perch/UI/SettingsView.swift index b66c42f..fe90a0b 100644 --- a/perch/UI/SettingsView.swift +++ b/perch/UI/SettingsView.swift @@ -19,6 +19,8 @@ struct SettingsView: View { .tabItem { Label(L10n.string("settings.language"), systemImage: "globe") } AIUsageTab() .tabItem { Label(L10n.string("settings.ai_usage"), systemImage: "cpu") } + PermissionsTab() + .tabItem { Label(L10n.string("settings.permissions"), systemImage: "checkmark.shield") } SettingsUpdateTab() .tabItem { Label(L10n.string("settings.updates"), systemImage: "arrow.down.circle") } } @@ -105,6 +107,66 @@ private struct NowPlayingTab: View { } } +private struct PermissionsTab: View { + @State private var store = PermissionsStore() + + var body: some View { + Form { + Section(L10n.string("settings.permissions.section")) { + ForEach(PermissionsStore.Kind.allCases) { kind in + row(for: kind) + } + } + } + .formStyle(.grouped) + .padding() + .onAppear { store.refreshAll() } + } + + @ViewBuilder + private func row(for kind: PermissionsStore.Kind) -> some View { + let status = store.statuses[kind] ?? .notDetermined + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(kind.displayName) + Text(statusLabel(status)) + .font(.caption) + .foregroundStyle(statusColor(status)) + } + Spacer() + if status != .authorized { + Button(actionLabel(status)) { + Task { await store.request(kind) } + } + } + } + } + + private func statusLabel(_ status: PermissionsStore.PermissionStatus) -> String { + switch status { + case .authorized: L10n.string("settings.permissions.status.authorized") + case .notDetermined: L10n.string("settings.permissions.status.not_determined") + case .denied: L10n.string("settings.permissions.status.denied") + case .unknown: L10n.string("settings.permissions.status.unknown") + } + } + + private func statusColor(_ status: PermissionsStore.PermissionStatus) -> Color { + switch status { + case .authorized: .green + case .notDetermined: .yellow + case .denied: .red + case .unknown: .secondary + } + } + + private func actionLabel(_ status: PermissionsStore.PermissionStatus) -> String { + status == .denied + ? L10n.string("settings.permissions.open_system_settings") + : L10n.string("settings.permissions.request") + } +} + private struct LanguageTab: View { @Binding var languageCode: String From 475e133322332f0d9c8ccbc424a5c8572a936cce Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 14:50:42 +0900 Subject: [PATCH 33/44] feat: add localized strings for the Permissions tab en/ja pairs for the tab label, section header, permission names, status labels, and action buttons. Verified both bundle correctly into the built app's .lproj resources. Co-Authored-By: Claude Opus 4.7 --- perch/Resources/en.lproj/Localizable.strings | 14 ++++++++++++++ perch/Resources/ja.lproj/Localizable.strings | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/perch/Resources/en.lproj/Localizable.strings b/perch/Resources/en.lproj/Localizable.strings index f12e6ca..2c6c5ac 100644 --- a/perch/Resources/en.lproj/Localizable.strings +++ b/perch/Resources/en.lproj/Localizable.strings @@ -10,6 +10,7 @@ "settings.nowplaying" = "Now Playing"; "settings.language" = "Language"; "settings.ai_usage" = "AI Usage"; +"settings.permissions" = "Permissions"; "settings.debug" = "Debug"; "settings.updates" = "Updates"; @@ -25,6 +26,19 @@ "settings.spotify" = "Spotify"; "settings.apple_music" = "Apple Music"; "settings.youtube_music" = "YouTube Music"; + +/* Settings — Permissions */ +"settings.permissions.section" = "Required Permissions"; +"settings.permissions.calendar" = "Calendar"; +"settings.permissions.screen_recording" = "Screen Recording"; +"settings.permissions.automation_spotify" = "Automation — Spotify"; +"settings.permissions.automation_apple_music" = "Automation — Apple Music"; +"settings.permissions.status.authorized" = "Granted"; +"settings.permissions.status.not_determined" = "Not requested yet"; +"settings.permissions.status.denied" = "Denied"; +"settings.permissions.status.unknown" = "Unknown (app not running)"; +"settings.permissions.request" = "Request"; +"settings.permissions.open_system_settings" = "Open System Settings"; "settings.show_source" = "Show music source in pill"; /* Settings — Language */ diff --git a/perch/Resources/ja.lproj/Localizable.strings b/perch/Resources/ja.lproj/Localizable.strings index 055fd72..f4aaa46 100644 --- a/perch/Resources/ja.lproj/Localizable.strings +++ b/perch/Resources/ja.lproj/Localizable.strings @@ -10,6 +10,7 @@ "settings.nowplaying" = "Now Playing"; "settings.language" = "言語"; "settings.ai_usage" = "AI使用状況"; +"settings.permissions" = "権限"; "settings.debug" = "デバッグ"; "settings.updates" = "アップデート"; @@ -25,6 +26,19 @@ "settings.spotify" = "Spotify"; "settings.apple_music" = "Apple Music"; "settings.youtube_music" = "YouTube Music"; + +/* Settings — Permissions */ +"settings.permissions.section" = "必要な権限"; +"settings.permissions.calendar" = "カレンダー"; +"settings.permissions.screen_recording" = "画面収録"; +"settings.permissions.automation_spotify" = "オートメーション — Spotify"; +"settings.permissions.automation_apple_music" = "オートメーション — Apple Music"; +"settings.permissions.status.authorized" = "許可済み"; +"settings.permissions.status.not_determined" = "未確認"; +"settings.permissions.status.denied" = "拒否済み"; +"settings.permissions.status.unknown" = "不明(アプリ未起動)"; +"settings.permissions.request" = "許可を求める"; +"settings.permissions.open_system_settings" = "システム設定を開く"; "settings.show_source" = "ピルに音楽ソースを表示"; /* Settings — Language */ From 436d9c0fc066e9440fe2a0726557a2e3b88e6f85 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 14:50:59 +0900 Subject: [PATCH 34/44] docs: check off Phase B11 tasks in progress.md Co-Authored-By: Claude Opus 4.7 --- docs/progress.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index dfd0487..f233bc6 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -841,10 +841,14 @@ Phase B10の`NSAppleEventsUsageDescription`追加後も実機でAutomation権限 できない。ユーザーには本タスク実装を待たずにシステム設定でMusicトグルをオフ→オン、または `tccutil reset AppleEvents com.tukuyomi032.perch`実行後の再起動を案内済み。 -- [ ] B11-1: `PermissionsStore`新規実装(Calendar/Screen Recording/Automation×2の状態確認・ - 付与ロジック) -- [ ] B11-2: `SettingsView`に`PermissionsTab`追加 -- [ ] B11-3: ローカライズ文字列追加(項目名・ボタン文言) +- [x] B11-1: `PermissionsStore`新規実装(Calendar/Screen Recording/Automation×2の状態確認・ + 付与ロジック)。コミット`a039945`。 +- [x] B11-2: `SettingsView`に`PermissionsTab`追加。コミット`364997c`。 +- [x] B11-3: ローカライズ文字列追加(項目名・ボタン文言)。コミット`475e133`。 + +Phase B11は3タスク全完了。実機検証時、Automation - Apple Musicが「拒否済み」表示になっている +はずなので、「システム設定を開く」ボタンでAutomationペインが実際に開くこと、システム設定側で +トグルをオフ→オンにした後「権限」タブを開き直すと状態が更新されることを確認する。 ### Phase D: バッテリー監視・アニメーション本格実装(未着手・タスク分割のみ) From 964067729a0e3d14a0fbce811cc9591ab2cb42a5 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 14:59:12 +0900 Subject: [PATCH 35/44] docs: add Phase B12 checklist for Settings width and lyrics revert Co-Authored-By: Claude Opus 4.7 --- docs/progress.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/progress.md b/docs/progress.md index f233bc6..0f820ee 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -850,6 +850,17 @@ Phase B11は3タスク全完了。実機検証時、Automation - Apple Musicが はずなので、「システム設定を開く」ボタンでAutomationペインが実際に開くこと、システム設定側で トグルをオフ→オンにした後「権限」タブを開き直すと状態が更新されることを確認する。 +### Phase B12: 設定ウィンドウ拡幅 + 歌詞を中央揃えに戻してフォント拡大(2026-07-31〜) + +Phase B11でタブが7つに増えたが`SettingsView.swift`のウィンドウ幅(420pt)が据え置きのままで、 +「権限」「アップデート」タブが`>>`オーバーフローに追いやられ開けない状態になっていた。あわせて +Phase B10で左寄せにした歌詞テキストを中央揃えに戻し、フォントサイズも拡大する。詳細は +`~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`のPhase B12セクション参照。 + +- [ ] B12-1: 設定ウィンドウの幅を拡大しタブ折り返しを解消(420pt→約600pt目安) +- [ ] B12-2: 歌詞テキストを左寄せ→中央揃えに戻す +- [ ] B12-3: 歌詞フォントサイズを13pt→15ptに拡大 + ### Phase D: バッテリー監視・アニメーション本格実装(未着手・タスク分割のみ) **参照**: `docs/macOS-Battery-Monitoring-Animation-Handbook-ja.md`(全39章、 From 43cbe3cdff7e8811387f460eb278cd01449418cb Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 15:00:11 +0900 Subject: [PATCH 36/44] fix: widen the Settings window so all 7 tabs fit without overflowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 420pt was sized for 6 tabs; adding Permissions in Phase B11 pushed it to 7, which no longer fit and pushed Permissions/Updates into TabView's ">>" overflow menu — unreachable per user report. Widened to 600pt so all tabs render in a single row. Co-Authored-By: Claude Opus 4.7 --- perch/UI/SettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/perch/UI/SettingsView.swift b/perch/UI/SettingsView.swift index fe90a0b..b2e8b53 100644 --- a/perch/UI/SettingsView.swift +++ b/perch/UI/SettingsView.swift @@ -24,7 +24,7 @@ struct SettingsView: View { SettingsUpdateTab() .tabItem { Label(L10n.string("settings.updates"), systemImage: "arrow.down.circle") } } - .frame(width: 420, height: 360) + .frame(width: 600, height: 360) .id(languageCode) } } From 516ee868cd8c114cde33b8304aedb58f0d3217f0 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 15:00:48 +0900 Subject: [PATCH 37/44] fix: revert lyrics text back to center alignment User reconsidered the Phase B10 left-alignment change and asked to go back to centered. Co-Authored-By: Claude Opus 4.7 --- perch/Features/NowPlaying/LyricsView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/perch/Features/NowPlaying/LyricsView.swift b/perch/Features/NowPlaying/LyricsView.swift index 12717ce..1e1cecf 100644 --- a/perch/Features/NowPlaying/LyricsView.swift +++ b/perch/Features/NowPlaying/LyricsView.swift @@ -36,15 +36,15 @@ struct LyricsView: View { } var body: some View { - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .center, spacing: 10) { ForEach(window, id: \.element.id) { idx, line in Text(line.text) .font(.system(size: fontSize, weight: .regular)) .foregroundStyle(.white.opacity(lineOpacity(idx))) .scaleEffect(idx == activeIndex ? 1.13 : 1.0, anchor: .center) - .multilineTextAlignment(.leading) + .multilineTextAlignment(.center) .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .center) .animation(.spring(response: 0.55, dampingFraction: 0.82), value: activeIndex) } } From 3007938801fa935209899467df1ef93d60f0cd61 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 15:02:06 +0900 Subject: [PATCH 38/44] feat: increase lyrics font size from 13pt to 15pt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumped LyricsView's default fontSize and NowPlayingLyricsColumn's explicit call site to match. Updated lyricsColumnHeight's derivation comment for the new font size — the 130pt value itself is unchanged, still a floor with a little slack over the ~118pt estimate. Co-Authored-By: Claude Opus 4.7 --- perch/Features/NowPlaying/LyricsView.swift | 2 +- perch/Features/NowPlaying/NowPlayingLyricsColumn.swift | 2 +- perch/UI/SurfaceMetrics.swift | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/perch/Features/NowPlaying/LyricsView.swift b/perch/Features/NowPlaying/LyricsView.swift index 1e1cecf..013a156 100644 --- a/perch/Features/NowPlaying/LyricsView.swift +++ b/perch/Features/NowPlaying/LyricsView.swift @@ -4,7 +4,7 @@ import SwiftUI struct LyricsView: View { let lines: [LyricsLine] let elapsedTime: TimeInterval - var fontSize: CGFloat = 13 + var fontSize: CGFloat = 15 private static let visibleLineCount = 4 diff --git a/perch/Features/NowPlaying/NowPlayingLyricsColumn.swift b/perch/Features/NowPlaying/NowPlayingLyricsColumn.swift index 7cae35e..df2767c 100644 --- a/perch/Features/NowPlaying/NowPlayingLyricsColumn.swift +++ b/perch/Features/NowPlaying/NowPlayingLyricsColumn.swift @@ -20,7 +20,7 @@ struct NowPlayingLyricsColumn: View { LyricsView( lines: lyrics, elapsedTime: state.liveElapsed(at: ctx.date) ?? 0, - fontSize: 13 + fontSize: 15 ) } } else { diff --git a/perch/UI/SurfaceMetrics.swift b/perch/UI/SurfaceMetrics.swift index 8a57376..7ea1491 100644 --- a/perch/UI/SurfaceMetrics.swift +++ b/perch/UI/SurfaceMetrics.swift @@ -23,9 +23,10 @@ nonisolated enum SurfaceMetrics { /// Rich mode's center column when it's showing lyrics (`NowPlayingLyricsColumn`) or /// today's events (`TodayEventsColumn`) — independent of whatever height the left - /// column happens to measure at. ~4 lines of `LyricsView` text (13pt font, 10pt - /// line spacing): `(13 * 1.3 + 10) * 4 ≈ 130pt`, where 13*1.3 approximates a single - /// line's rendered height (font size × typical line-height multiplier). + /// column happens to measure at. ~4 lines of `LyricsView` text (15pt font, 10pt + /// line spacing): `(15 * 1.3 + 10) * 4 ≈ 118pt`, where 15*1.3 approximates a single + /// line's rendered height (font size × typical line-height multiplier); 130 keeps a + /// little slack over that estimate. static let lyricsColumnHeight: CGFloat = 130 /// `IslandTopBar`'s own rendered height (10pt top padding + 26pt tallest control + From 541c6ce1571da8b1099f0b65f8a0bce786724ec9 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 15:02:24 +0900 Subject: [PATCH 39/44] docs: check off Phase B12 tasks in progress.md Co-Authored-By: Claude Opus 4.7 --- docs/progress.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index 0f820ee..4804c8a 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -857,9 +857,13 @@ Phase B11でタブが7つに増えたが`SettingsView.swift`のウィンドウ Phase B10で左寄せにした歌詞テキストを中央揃えに戻し、フォントサイズも拡大する。詳細は `~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`のPhase B12セクション参照。 -- [ ] B12-1: 設定ウィンドウの幅を拡大しタブ折り返しを解消(420pt→約600pt目安) -- [ ] B12-2: 歌詞テキストを左寄せ→中央揃えに戻す -- [ ] B12-3: 歌詞フォントサイズを13pt→15ptに拡大 +- [x] B12-1: 設定ウィンドウの幅を拡大しタブ折り返しを解消(420pt→600pt)。コミット`43cbe3c`。 +- [x] B12-2: 歌詞テキストを左寄せ→中央揃えに戻す。コミット`516ee86`。 +- [x] B12-3: 歌詞フォントサイズを13pt→15ptに拡大。コミット`3007938`。 + +Phase B12は3タスク全完了。実機検証時、設定画面7タブ全てがオーバーフロー無しで開けること、 +歌詞が中央揃え・大きめフォントで表示され`lyricsColumnHeight`(130pt)枠からはみ出していないことを +確認する。 ### Phase D: バッテリー監視・アニメーション本格実装(未着手・タスク分割のみ) From d22c4e2b815f7e47401ef173e192892802d462bb Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 15:35:17 +0900 Subject: [PATCH 40/44] docs: add Phase B13 checklist for exclusive source selection and startup detection Co-Authored-By: Claude Opus 4.7 --- docs/progress.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/progress.md b/docs/progress.md index 4804c8a..d326278 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -865,6 +865,25 @@ Phase B12は3タスク全完了。実機検証時、設定画面7タブ全てが 歌詞が中央揃え・大きめフォントで表示され`lyricsColumnHeight`(130pt)枠からはみ出していないことを 確認する。 +### Phase B13: Now Playingソース排他選択化 + 起動時アクティブ検知 + PR作成・マージ(2026-07-31〜) + +設定画面のNow Playingソーストグル(Spotify/Apple Music/YouTube Music)が実際の動作に反映 +されていない、Atoll型の排他Picker(自動含む4択)にしたい、Perch起動時に既に再生中の音楽が +検知されない(一度停止するまで検知されない)、の3件を修正。加えてPhase B12のフォントサイズ +拡大で歌詞の長い行が省略記号で切り詰められる問題(`lineLimit`緩和で対応)も修正。全完了後PRを +作成しmainへマージする。詳細は`~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`の +Phase B13セクション参照。 + +- [ ] B13-1: 歌詞`lineLimit`緩和(省略記号切り詰めの解消) +- [ ] B13-2: `NowPlayingSourcePreference`新規enum + `Defaults.Key`追加 + 旧3Bool削除 +- [ ] B13-3: 旧設定からの移行処理 + テスト +- [ ] B13-4: `SettingsView`のNowPlayingTabを排他Pickerに置き換え +- [ ] B13-5: `applyState`の排他ゲート化 +- [ ] B13-6: 設定変更の即時反映(Defaults監視+currentStateクリア) +- [ ] B13-7: 起動時・切替時のアクティブ検知(Spotify/Apple Music/YouTube Music) +- [ ] B13-8: ローカライズ文字列更新(Pickerの選択肢名) +- [ ] B13-9: PR作成 → レビュー登録 → マージ + ### Phase D: バッテリー監視・アニメーション本格実装(未着手・タスク分割のみ) **参照**: `docs/macOS-Battery-Monitoring-Animation-Handbook-ja.md`(全39章、 From e036b21c59afaceccde9433c926b7808ee00cf80 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 15:36:32 +0900 Subject: [PATCH 41/44] fix: raise lyrics lineLimit from 2 to 3 to stop truncation at the larger font size Phase B12's font bump (13pt -> 15pt) pushed some lyric lines from 2 wrapped lines to 3, hitting lineLimit(2) and truncating with an ellipsis instead of wrapping fully. scaleEffect on the active line doesn't affect layout/wrapping (it's a post-layout render transform), so this affected any line long enough to need a third line, not just the active one. Co-Authored-By: Claude Opus 4.7 --- perch/Features/NowPlaying/LyricsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/perch/Features/NowPlaying/LyricsView.swift b/perch/Features/NowPlaying/LyricsView.swift index 013a156..69e1287 100644 --- a/perch/Features/NowPlaying/LyricsView.swift +++ b/perch/Features/NowPlaying/LyricsView.swift @@ -43,7 +43,7 @@ struct LyricsView: View { .foregroundStyle(.white.opacity(lineOpacity(idx))) .scaleEffect(idx == activeIndex ? 1.13 : 1.0, anchor: .center) .multilineTextAlignment(.center) - .lineLimit(2) + .lineLimit(3) .frame(maxWidth: .infinity, alignment: .center) .animation(.spring(response: 0.55, dampingFraction: 0.82), value: activeIndex) } From 982809ce5d113d2dd7a65cc2c9a2df7a50b09c15 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 15:41:35 +0900 Subject: [PATCH 42/44] feat: replace independent Now Playing source toggles with an exclusive picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs fixed: 1. The three Spotify/Apple Music/YouTube Music toggles were checked only when a new notification arrived, with no way to clear an already-active state or react to the toggle itself changing — turning a source off while it was playing left its activity showing indefinitely. Replaced with a single NowPlayingSourcePreference (auto/spotify/appleMusic/youTubeMusic), gated once in applyState, and observed via Defaults.observe so a change clears currentState immediately when it no longer matches. 2. A track already playing when Perch launches was never detected — Spotify/Music's DistributedNotifications only fire on a state *transition*, so nothing arrived until the user paused/resumed. Added active AppleScript queries (detectActiveSpotifyState, detectActiveAppleMusicState) plus a MediaRemoteBridge.currentState read for YouTube Music, run once at launch and again whenever the preferred source changes. Settings' NowPlaying tab now shows one menu-style Picker instead of three toggles, matching the single-select pattern requested. Co-Authored-By: Claude Opus 4.7 --- perch/Core/NowPlayingSourcePreference.swift | 36 +++++ perch/Core/Preferences.swift | 9 +- .../NowPlaying/NowPlayingManager.swift | 129 ++++++++++++++++-- perch/Resources/en.lproj/Localizable.strings | 2 + perch/Resources/ja.lproj/Localizable.strings | 2 + perch/UI/SettingsView.swift | 13 +- 6 files changed, 174 insertions(+), 17 deletions(-) create mode 100644 perch/Core/NowPlayingSourcePreference.swift diff --git a/perch/Core/NowPlayingSourcePreference.swift b/perch/Core/NowPlayingSourcePreference.swift new file mode 100644 index 0000000..5112165 --- /dev/null +++ b/perch/Core/NowPlayingSourcePreference.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Which Now Playing source(s) Perch pays attention to, set from Settings' Now Playing tab. +/// +/// `.auto` keeps the original behavior — whichever source is actually playing wins, via +/// `NowPlayingManager`'s existing priority arbitration (`sourcePriority(_:)`). Picking a +/// specific source makes it exclusive: activity from every other source is ignored +/// entirely, matching the single-select "Music Source" picker in comparable apps. +enum NowPlayingSourcePreference: String, CaseIterable, Sendable, Identifiable { + case auto + case spotify + case appleMusic + case youTubeMusic + + nonisolated var id: Self { self } + + nonisolated var displayNameKey: String { + switch self { + case .auto: "settings.nowplaying_source.auto" + case .spotify: "settings.spotify" + case .appleMusic: "settings.apple_music" + case .youTubeMusic: "settings.youtube_music" + } + } + + /// The `MusicSource` this preference restricts activity to, or `nil` for `.auto` + /// (no restriction — every source is allowed through). + nonisolated var exclusiveSource: MusicSource? { + switch self { + case .auto: nil + case .spotify: .spotify + case .appleMusic: .appleMusic + case .youTubeMusic: .youTubeMusic + } + } +} diff --git a/perch/Core/Preferences.swift b/perch/Core/Preferences.swift index 1f762fe..77b09fb 100644 --- a/perch/Core/Preferences.swift +++ b/perch/Core/Preferences.swift @@ -6,15 +6,18 @@ import Foundation /// in that file, so the vendored-adjacent source stays free of Perch's persistence choice. extension ScreenPreference: Defaults.Serializable {} extension UpdateChannel: Defaults.Serializable {} +extension NowPlayingSourcePreference: Defaults.Serializable {} extension Defaults.Keys { static let launchAtLogin = Key("launchAtLogin", default: false) static let showInAllSpaces = Key("showInAllSpaces", default: true) static let autoCollapseDelay = Key("autoCollapseDelay", default: 3.0) static let showNowPlayingSource = Key("showNowPlayingSource", default: false) - static let enableSpotify = Key("enableSpotify", default: true) - static let enableAppleMusic = Key("enableAppleMusic", default: true) - static let enableYouTubeMusic = Key("enableYouTubeMusic", default: true) + /// Replaces the old `enableSpotify`/`enableAppleMusic`/`enableYouTubeMusic` triple of + /// independent toggles; existing installs are migrated once at launch by + /// `PreferencesMigration`. + static let preferredNowPlayingSource = Key( + "preferredNowPlayingSource", default: .auto) static let languageCode = Key("languageCode", default: "en") static let updateChannel = Key("updateChannel", default: .stable) static let aiRefreshInterval = Key("aiRefreshInterval", default: .fiveMinutes) diff --git a/perch/Features/NowPlaying/NowPlayingManager.swift b/perch/Features/NowPlaying/NowPlayingManager.swift index 90f92c9..6beeefb 100644 --- a/perch/Features/NowPlaying/NowPlayingManager.swift +++ b/perch/Features/NowPlaying/NowPlayingManager.swift @@ -45,6 +45,9 @@ final class NowPlayingManager { private nonisolated(unsafe) var amPositionTask: Task? private nonisolated(unsafe) var lyricsPrefetchTask: Task? private nonisolated(unsafe) var mediaRemoteStateTask: Task? + // Same non-Sendability shape as `IslandHost`'s preference observations — see its + // `chromeStyleObservation` comment. + private nonisolated(unsafe) var preferredSourceObservation: (any Defaults.Observation)? private var isYTMPolling: Bool = false private var wasYTMPolling: Bool = false // Bounded retry for Apple Music artwork: `com.apple.Music.playerInfo` only fires on @@ -104,6 +107,27 @@ final class NowPlayingManager { } } } + + // Every observer above is purely reactive — Spotify/Music's DistributedNotification + // only fires on a state *transition*, so a track already playing when Perch launches + // produces no event until the user pauses/resumes it. Seed currentState with an + // active query instead of waiting for that. + Task { @MainActor [weak self] in await self?.refreshActiveState() } + + // Switching the preferred source in Settings should take effect immediately: drop + // whatever's showing if it's no longer the selected source, then actively re-detect + // the new selection rather than waiting for its next notification. + preferredSourceObservation = Defaults.observe(.preferredNowPlayingSource) { [weak self] change in + Task { @MainActor [weak self] in + guard let self else { return } + if let exclusiveSource = change.newValue.exclusiveSource, + self.currentState?.source != exclusiveSource + { + self.currentState = nil + } + await self.refreshActiveState() + } + } } deinit { @@ -114,9 +138,99 @@ final class NowPlayingManager { amPositionTask?.cancel() lyricsPrefetchTask?.cancel() mediaRemoteStateTask?.cancel() + preferredSourceObservation?.invalidate() Task { @MainActor in MediaRemoteBridge.shared.stop() } } + // MARK: - Active State Detection + + /// Actively queries whichever source(s) the current preference allows, so a track + /// already playing before Perch started (or before a source switch) is picked up + /// immediately instead of waiting for the next play/pause/track-change notification. + private func refreshActiveState() async { + let preference = Defaults[.preferredNowPlayingSource] + if let exclusiveSource = preference.exclusiveSource { + if let state = await detectActiveState(for: exclusiveSource) { + applyState(state, source: state.source.rawValue) + } + return + } + // `.auto`: try every source; applyState's existing priority arbitration + // (`sourcePriority(_:)`) resolves which one wins if more than one is playing. + for source in [MusicSource.spotify, .appleMusic, .youTubeMusic] { + if let state = await detectActiveState(for: source) { + applyState(state, source: state.source.rawValue) + } + } + } + + private func detectActiveState(for source: MusicSource) async -> NowPlayingState? { + switch source { + case .spotify: return await detectActiveSpotifyState() + case .appleMusic: return await detectActiveAppleMusicState() + case .youTubeMusic: return detectActiveYouTubeMusicState() + case .mrMediaRemote: return nil + } + } + + private func detectActiveSpotifyState() async -> NowPlayingState? { + let script = """ + tell application "Spotify" + if not running then return "NOTRUNNING" + if player state is stopped then return "STOPPED" + set st to player state as string + set n to name of current track + set a to artist of current track + set al to album of current track + set d to duration of current track + set p to player position + return st & "\\n" & n & "\\n" & a & "\\n" & al & "\\n" & (d as string) & "\\n" & (p as string) + end tell + """ + guard let result = await runAppleScript(script) else { return nil } + let lines = result.components(separatedBy: "\n") + guard lines.count >= 6, lines[0] != "NOTRUNNING", lines[0] != "STOPPED" else { return nil } + return NowPlayingState( + spotifyPlayerState: lines[0], title: lines[1], artist: lines[2], + album: lines[3].isEmpty ? nil : lines[3], + durationMs: Double(lines[4]), position: Double(lines[5]) + ) + } + + private func detectActiveAppleMusicState() async -> NowPlayingState? { + let script = """ + tell application "Music" + if not running then return "NOTRUNNING" + if player state is stopped then return "STOPPED" + set st to player state as string + set n to name of current track + set a to artist of current track + set al to album of current track + set d to duration of current track + return st & "\\n" & n & "\\n" & a & "\\n" & al & "\\n" & (d as string) + end tell + """ + guard let result = await runAppleScript(script) else { return nil } + let lines = result.components(separatedBy: "\n") + guard lines.count >= 5, lines[0] != "NOTRUNNING", lines[0] != "STOPPED" else { return nil } + // Music.app's AppleScript `duration` property is in seconds; the initializer expects + // the same millisecond convention as the "Total Time" notification field. + let totalTimeMs = Double(lines[4]).map { $0 * 1000 } + return NowPlayingState( + appleMusicPlayerState: lines[0], title: lines[1], artist: lines[2], + album: lines[3].isEmpty ? nil : lines[3], totalTime: totalTimeMs + ) + } + + /// `MediaRemoteBridge` already tracks the live state reactively (it's the source that + /// feeds `stateUpdates`), so this is a synchronous read rather than a new query — gated + /// on `isYTMPolling` the same way the stream consumer above is, so a stale state isn't + /// applied when no YTM-capable app is actually running. + private func detectActiveYouTubeMusicState() -> NowPlayingState? { + guard isYTMPolling else { return nil } + return MediaRemoteBridge.shared.currentState + } + // MARK: - Playback Controls func togglePlayPause() { @@ -545,14 +659,13 @@ final class NowPlayingManager { // MARK: - State Application private func applyState(_ newState: NowPlayingState?, source: String) { - // Respect per-source enable settings - if let incoming = newState { - switch incoming.source { - case .spotify where !Defaults[.enableSpotify]: return - case .appleMusic where !Defaults[.enableAppleMusic]: return - case .youTubeMusic where !Defaults[.enableYouTubeMusic]: return - default: break - } + // `.auto` accepts every source (existing priority arbitration below decides which + // wins); a specific preference ignores every source but that one entirely. + if let incoming = newState, + let exclusiveSource = Defaults[.preferredNowPlayingSource].exclusiveSource, + incoming.source != exclusiveSource + { + return } // Prevent lower-priority source from clearing higher-priority active state. // MRMediaRemote returning nil (blocked on macOS 16) must not override Spotify. diff --git a/perch/Resources/en.lproj/Localizable.strings b/perch/Resources/en.lproj/Localizable.strings index 2c6c5ac..48669bd 100644 --- a/perch/Resources/en.lproj/Localizable.strings +++ b/perch/Resources/en.lproj/Localizable.strings @@ -23,6 +23,8 @@ /* Settings — Now Playing */ "settings.sources" = "Sources"; +"settings.nowplaying_source" = "Music Source"; +"settings.nowplaying_source.auto" = "Automatic (any source)"; "settings.spotify" = "Spotify"; "settings.apple_music" = "Apple Music"; "settings.youtube_music" = "YouTube Music"; diff --git a/perch/Resources/ja.lproj/Localizable.strings b/perch/Resources/ja.lproj/Localizable.strings index f4aaa46..8d2ce32 100644 --- a/perch/Resources/ja.lproj/Localizable.strings +++ b/perch/Resources/ja.lproj/Localizable.strings @@ -23,6 +23,8 @@ /* Settings — Now Playing */ "settings.sources" = "ソース"; +"settings.nowplaying_source" = "音楽ソース"; +"settings.nowplaying_source.auto" = "自動(どのソースでも検知)"; "settings.spotify" = "Spotify"; "settings.apple_music" = "Apple Music"; "settings.youtube_music" = "YouTube Music"; diff --git a/perch/UI/SettingsView.swift b/perch/UI/SettingsView.swift index b2e8b53..5cc73f3 100644 --- a/perch/UI/SettingsView.swift +++ b/perch/UI/SettingsView.swift @@ -90,16 +90,17 @@ private struct IslandTab: View { } private struct NowPlayingTab: View { - @Default(.enableSpotify) private var enableSpotify - @Default(.enableAppleMusic) private var enableAppleMusic - @Default(.enableYouTubeMusic) private var enableYouTubeMusic + @Default(.preferredNowPlayingSource) private var preferredNowPlayingSource var body: some View { Form { Section(L10n.string("settings.sources")) { - Toggle(L10n.string("settings.spotify"), isOn: $enableSpotify) - Toggle(L10n.string("settings.apple_music"), isOn: $enableAppleMusic) - Toggle(L10n.string("settings.youtube_music"), isOn: $enableYouTubeMusic) + Picker(L10n.string("settings.nowplaying_source"), selection: $preferredNowPlayingSource) { + ForEach(NowPlayingSourcePreference.allCases) { source in + Text(L10n.string(source.displayNameKey)).tag(source) + } + } + .pickerStyle(.menu) } } .formStyle(.grouped) From 3d2e8b3a23aac8053c351f42a478caffc38ae114 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 15:44:50 +0900 Subject: [PATCH 43/44] feat: migrate the legacy source toggles to preferredNowPlayingSource Follows migrateIslandChromeStyle's shape: gated on the legacy keys actually having been written (object(forKey:) != nil), consuming them once migrated. Exactly one enabled source carries over as that explicit choice; the old all-enabled default and any other combination fall back to .auto. Added PreferencesMigrationTests coverage for the pure mapping function and the store-level migration. Co-Authored-By: Claude Opus 4.7 --- perch/Core/PreferencesMigration.swift | 51 +++++++++++++++++ .../Core/PreferencesMigrationTests.swift | 55 +++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/perch/Core/PreferencesMigration.swift b/perch/Core/PreferencesMigration.swift index 195efb7..c46313e 100644 --- a/perch/Core/PreferencesMigration.swift +++ b/perch/Core/PreferencesMigration.swift @@ -27,15 +27,66 @@ enum PreferencesMigration { /// tinted the capsule `CompactPillView` drew, and the backdrop is now the vendored /// surface's, chosen by `NookBridge.makeBackdrop(reduceTransparency:)`. static let pillBackgroundStyle = "pillBackgroundStyle" + /// The three independent `Bool` toggles (each defaulted `true`), replaced by the + /// single `preferredNowPlayingSource` picker. + static let enableSpotify = "enableSpotify" + static let enableAppleMusic = "enableAppleMusic" + static let enableYouTubeMusic = "enableYouTubeMusic" } /// Runs every pending migration. Call once, early, before any `Defaults[...]` read that /// a migration is meant to populate. static func runAll(in store: UserDefaults = .standard) { migrateIslandChromeStyle(in: store) + migrateNowPlayingSourceToggles(in: store) removeRetiredKeys(in: store) } + /// Carries the three legacy source toggles over to `preferredNowPlayingSource`, then + /// drops them. + /// + /// Gated on "at least one of the three was ever explicitly written," the same shape as + /// ``migrateIslandChromeStyle(in:)`` — the toggles were removed from `Defaults.Keys` in + /// this release, so `register(defaults:)` no longer runs for them and `object(forKey:)` + /// is `nil` unless the user actually flipped one in a previous build. A user who never + /// touched any of them had the old all-enabled default, which is exactly what `.auto` + /// means now, so there is nothing to migrate. + static func migrateNowPlayingSourceToggles(in store: UserDefaults = .standard) { + guard + store.object(forKey: LegacyKey.enableSpotify) != nil + || store.object(forKey: LegacyKey.enableAppleMusic) != nil + || store.object(forKey: LegacyKey.enableYouTubeMusic) != nil + else { return } + + let migrated = Self.migrateSourcePreference( + spotifyEnabled: store.object(forKey: LegacyKey.enableSpotify) as? Bool ?? true, + appleMusicEnabled: store.object(forKey: LegacyKey.enableAppleMusic) as? Bool ?? true, + youTubeMusicEnabled: store.object(forKey: LegacyKey.enableYouTubeMusic) as? Bool ?? true + ) + store.set(migrated.rawValue, forKey: Defaults.Keys.preferredNowPlayingSource.name) + + store.removeObject(forKey: LegacyKey.enableSpotify) + store.removeObject(forKey: LegacyKey.enableAppleMusic) + store.removeObject(forKey: LegacyKey.enableYouTubeMusic) + } + + /// Pure, so the mapping is unit-testable without touching `UserDefaults`. Exactly one + /// enabled source carries over as that explicit choice; the old default (all three + /// enabled) and any other combination (two enabled, none enabled — states the toggle UI + /// could reach but that have no equivalent in the new four-way picker) fall back to + /// `.auto`, which is strictly more permissive than any of those combinations and never + /// drops activity the user was seeing. + nonisolated static func migrateSourcePreference( + spotifyEnabled: Bool, appleMusicEnabled: Bool, youTubeMusicEnabled: Bool + ) -> NowPlayingSourcePreference { + switch (spotifyEnabled, appleMusicEnabled, youTubeMusicEnabled) { + case (true, false, false): return .spotify + case (false, true, false): return .appleMusic + case (false, false, true): return .youTubeMusic + default: return .auto + } + } + /// Carries a persisted `notchSimulationMode` over to `islandChromeStyle`, then drops /// the legacy key. /// diff --git a/perchTests/Core/PreferencesMigrationTests.swift b/perchTests/Core/PreferencesMigrationTests.swift index d1db913..92a3f54 100644 --- a/perchTests/Core/PreferencesMigrationTests.swift +++ b/perchTests/Core/PreferencesMigrationTests.swift @@ -26,11 +26,16 @@ import Testing struct PreferencesMigrationTests { private static let chromeKey = Defaults.Keys.islandChromeStyle.name private static let legacyChromeKey = PreferencesMigration.LegacyKey.notchSimulationMode + private static let sourceKey = Defaults.Keys.preferredNowPlayingSource.name private static let touchedKeys = [ chromeKey, legacyChromeKey, PreferencesMigration.LegacyKey.pillSize, PreferencesMigration.LegacyKey.pillBackgroundStyle, + sourceKey, + PreferencesMigration.LegacyKey.enableSpotify, + PreferencesMigration.LegacyKey.enableAppleMusic, + PreferencesMigration.LegacyKey.enableYouTubeMusic, ] /// Snapshots every key the migration touches, hands the caller a clean slate, and @@ -136,6 +141,56 @@ struct PreferencesMigrationTests { } } + @Test( + "migrateSourcePreference maps exactly-one-enabled to that source, everything else to .auto", + arguments: [ + (true, false, false, NowPlayingSourcePreference.spotify), + (false, true, false, NowPlayingSourcePreference.appleMusic), + (false, false, true, NowPlayingSourcePreference.youTubeMusic), + (true, true, true, NowPlayingSourcePreference.auto), + (false, false, false, NowPlayingSourcePreference.auto), + (true, true, false, NowPlayingSourcePreference.auto), + ] + ) + func migrateSourcePreferenceMapping( + spotify: Bool, appleMusic: Bool, youTubeMusic: Bool, expected: NowPlayingSourcePreference + ) { + let result = PreferencesMigration.migrateSourcePreference( + spotifyEnabled: spotify, appleMusicEnabled: appleMusic, youTubeMusicEnabled: youTubeMusic) + #expect(result == expected) + } + + @Test("a legacy source toggle set is migrated to the matching exclusive preference") + func migratesLegacySourceToggle() { + withCleanStore { store in + store.set(false, forKey: PreferencesMigration.LegacyKey.enableSpotify) + store.set(true, forKey: PreferencesMigration.LegacyKey.enableAppleMusic) + store.set(false, forKey: PreferencesMigration.LegacyKey.enableYouTubeMusic) + PreferencesMigration.migrateNowPlayingSourceToggles(in: store) + #expect(store.string(forKey: Self.sourceKey) == NowPlayingSourcePreference.appleMusic.rawValue) + } + } + + @Test("consuming the legacy source toggles makes the migration one-shot") + func consumesLegacySourceToggles() { + withCleanStore { store in + store.set(false, forKey: PreferencesMigration.LegacyKey.enableSpotify) + PreferencesMigration.migrateNowPlayingSourceToggles(in: store) + #expect(store.object(forKey: PreferencesMigration.LegacyKey.enableSpotify) == nil) + #expect(store.object(forKey: PreferencesMigration.LegacyKey.enableAppleMusic) == nil) + #expect(store.object(forKey: PreferencesMigration.LegacyKey.enableYouTubeMusic) == nil) + } + } + + @Test("a fresh install with no legacy source toggles is left alone") + func freshInstallSourceTogglesUntouched() { + withCleanStore { store in + store.set(NowPlayingSourcePreference.spotify.rawValue, forKey: Self.sourceKey) + PreferencesMigration.migrateNowPlayingSourceToggles(in: store) + #expect(store.string(forKey: Self.sourceKey) == NowPlayingSourcePreference.spotify.rawValue) + } + } + /// Pins the assumption the migration's raw-value write depends on: `Defaults` persists /// a `String`-backed `RawRepresentable` as the bare raw string. If a `Defaults` upgrade /// ever wrapped it (JSON, a plist dict), the migration would still write a plain string From e190f0c9235c492942482e5958fd971900eaa0ac Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 31 Jul 2026 15:45:44 +0900 Subject: [PATCH 44/44] docs: check off Phase B13 tasks, survey next-phase candidates Co-Authored-By: Claude Opus 4.7 --- docs/progress.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index d326278..c3df141 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -874,16 +874,23 @@ Phase B12は3タスク全完了。実機検証時、設定画面7タブ全てが 作成しmainへマージする。詳細は`~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`の Phase B13セクション参照。 -- [ ] B13-1: 歌詞`lineLimit`緩和(省略記号切り詰めの解消) -- [ ] B13-2: `NowPlayingSourcePreference`新規enum + `Defaults.Key`追加 + 旧3Bool削除 -- [ ] B13-3: 旧設定からの移行処理 + テスト -- [ ] B13-4: `SettingsView`のNowPlayingTabを排他Pickerに置き換え -- [ ] B13-5: `applyState`の排他ゲート化 -- [ ] B13-6: 設定変更の即時反映(Defaults監視+currentStateクリア) -- [ ] B13-7: 起動時・切替時のアクティブ検知(Spotify/Apple Music/YouTube Music) -- [ ] B13-8: ローカライズ文字列更新(Pickerの選択肢名) +- [x] B13-1: 歌詞`lineLimit`緩和(省略記号切り詰めの解消)。コミット`e036b21`。 +- [x] B13-2〜7: `NowPlayingSourcePreference`新規enum + `Defaults.Key`追加 + 旧3Bool削除、 + `SettingsView`のNowPlayingTabを排他Pickerに置き換え、`applyState`の排他ゲート化、 + 設定変更の即時反映(Defaults監視+currentStateクリア)、起動時・切替時のアクティブ検知 + (Spotify/Apple Music/YouTube Music)、ローカライズ文字列更新。一体の変更のため + 1コミットにまとめた。コミット`982809c`。 +- [x] B13-3(移行処理+テスト、上記とは別コミット): 旧3Boolトグルから + `preferredNowPlayingSource`への移行処理を追加、`PreferencesMigrationTests`に + 純粋関数マッピング・移行実行・冪等性のテストを追加。コミット`3d2e8b3`。 - [ ] B13-9: PR作成 → レビュー登録 → マージ +Phase B4〜B13の一連の実機フィードバック対応はこれでB13-9(PR作成・マージ)を残すのみ。 +次フェーズ候補の棚卸し: Phase D(バッテリー監視)・Phase E(WiFi拡張)・Phase F(メディア +プレイヤー)はいずれも「未着手・タスク分割のみ」で緊急性なし。**Phase C(波形の実音キャプチャ +修理)はC1・C2のみ完了・C0とC3〜C9が未着手**——ユーザー希望のオーディオスペクトラム完成と +一致する、優先度上も次に着手して差し支えない状態。 + ### Phase D: バッテリー監視・アニメーション本格実装(未着手・タスク分割のみ) **参照**: `docs/macOS-Battery-Monitoring-Animation-Handbook-ja.md`(全39章、