Skip to content

Commit df5f63b

Browse files
authored
feat: video support (#46)
1 parent 4855d03 commit df5f63b

27 files changed

+2507
-970
lines changed

App/VideoWindowController.swift

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import AppKit
2+
import SwiftUI
3+
4+
// MARK: - VideoWindowController
5+
6+
/// Manages the floating video window.
7+
@available(macOS 26.0, *)
8+
@MainActor
9+
final class VideoWindowController {
10+
static let shared = VideoWindowController()
11+
12+
private var window: NSWindow?
13+
private var hostingView: NSHostingView<AnyView>?
14+
private let logger = DiagnosticsLogger.player
15+
16+
/// Reference to PlayerService to sync showVideo state
17+
private weak var playerService: PlayerService?
18+
19+
/// Flag to prevent re-entrant close handling
20+
private var isClosing = false
21+
22+
// Corner snapping
23+
enum Corner: Int {
24+
case topLeft, topRight, bottomLeft, bottomRight
25+
}
26+
27+
private var currentCorner: Corner = .bottomRight
28+
29+
private init() {
30+
self.loadCorner()
31+
}
32+
33+
/// Shows the video window.
34+
func show(
35+
playerService: PlayerService,
36+
webKitManager: WebKitManager
37+
) {
38+
self.logger.debug("VideoWindowController.show() called")
39+
40+
// Store reference to sync state on close
41+
self.playerService = playerService
42+
43+
// Start grace period to prevent race condition when video element is moved
44+
playerService.videoWindowDidOpen()
45+
46+
if let existingWindow = self.window {
47+
// Window exists - just bring it to front
48+
self.logger.debug("Window already exists, bringing to front")
49+
self.isClosing = false // Reset in case of interrupted close
50+
existingWindow.makeKeyAndOrderFront(nil)
51+
// Ensure video mode is active
52+
SingletonPlayerWebView.shared.updateDisplayMode(.video)
53+
return
54+
}
55+
56+
self.logger.info("Creating new video window")
57+
58+
let contentView = VideoPlayerWindow()
59+
.environment(playerService)
60+
.environment(webKitManager)
61+
62+
let hostingView = NSHostingView(rootView: AnyView(contentView))
63+
self.hostingView = hostingView
64+
65+
let window = NSWindow(
66+
contentRect: NSRect(x: 0, y: 0, width: 480, height: 270),
67+
styleMask: [.titled, .closable, .resizable, .fullSizeContentView],
68+
backing: .buffered,
69+
defer: false
70+
)
71+
72+
window.contentView = hostingView
73+
window.isReleasedWhenClosed = false
74+
window.title = "Video"
75+
window.titlebarAppearsTransparent = true
76+
window.titleVisibility = .hidden
77+
window.isMovableByWindowBackground = true
78+
// Normal window level (not always-on-top) for better UX
79+
window.level = .normal
80+
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
81+
window.aspectRatio = NSSize(width: 16, height: 9)
82+
window.minSize = NSSize(width: 320, height: 180)
83+
window.backgroundColor = .black
84+
85+
// Set accessibility identifier for UI testing
86+
window.identifier = NSUserInterfaceItemIdentifier(AccessibilityID.VideoWindow.container)
87+
88+
// Position at saved corner
89+
self.positionAtCorner(window: window, corner: self.currentCorner)
90+
91+
window.makeKeyAndOrderFront(nil)
92+
self.window = window
93+
self.isClosing = false
94+
95+
// Observe window close (for red X button)
96+
NotificationCenter.default.addObserver(
97+
self,
98+
selector: #selector(self.windowWillClose),
99+
name: NSWindow.willCloseNotification,
100+
object: window
101+
)
102+
103+
// Update WebView display mode for video
104+
self.logger.info("Calling updateDisplayMode(.video)")
105+
SingletonPlayerWebView.shared.updateDisplayMode(.video)
106+
}
107+
108+
/// Closes the video window programmatically (called when showVideo becomes false).
109+
func close() {
110+
self.logger.debug("VideoWindowController.close() called")
111+
112+
// Prevent re-entrant calls
113+
guard !self.isClosing else {
114+
self.logger.debug("Already closing, skipping")
115+
return
116+
}
117+
118+
guard let window = self.window else {
119+
self.logger.debug("No window to close")
120+
return
121+
}
122+
123+
self.isClosing = true
124+
self.logger.info("Closing video window")
125+
126+
// Remove observer before closing to prevent windowWillClose from firing
127+
NotificationCenter.default.removeObserver(self, name: NSWindow.willCloseNotification, object: window)
128+
129+
// Save corner position
130+
self.currentCorner = self.nearestCorner(for: window)
131+
self.saveCorner()
132+
133+
// Clean up
134+
self.performCleanup()
135+
136+
// Actually close the window
137+
window.close()
138+
}
139+
140+
/// Called when window is closed via the red X button.
141+
@objc private func windowWillClose(_ notification: Notification) {
142+
self.logger.info("windowWillClose notification received")
143+
144+
// Prevent re-entrant calls
145+
guard !self.isClosing else {
146+
self.logger.debug("Already closing, skipping windowWillClose")
147+
return
148+
}
149+
self.isClosing = true
150+
151+
// Update corner based on final position
152+
if let window = notification.object as? NSWindow {
153+
self.currentCorner = self.nearestCorner(for: window)
154+
self.saveCorner()
155+
}
156+
157+
// Clean up
158+
self.performCleanup()
159+
160+
// Sync PlayerService state - this handles close via red button
161+
// This will trigger MainWindow.onChange which calls close(), but isClosing prevents re-entry
162+
if self.playerService?.showVideo == true {
163+
self.logger.debug("Syncing playerService.showVideo to false")
164+
self.playerService?.showVideo = false
165+
}
166+
}
167+
168+
/// Shared cleanup logic for both close paths.
169+
private func performCleanup() {
170+
self.logger.debug("performCleanup called")
171+
172+
// Clear grace period
173+
self.playerService?.videoWindowDidClose()
174+
175+
// Return WebView to hidden mode (removes video container CSS)
176+
SingletonPlayerWebView.shared.updateDisplayMode(.hidden)
177+
178+
// Clear references
179+
self.window = nil
180+
self.hostingView = nil
181+
182+
// Reset close guard so future close operations can proceed
183+
self.isClosing = false
184+
}
185+
186+
private func positionAtCorner(window: NSWindow, corner: Corner) {
187+
guard let screen = NSScreen.main else { return }
188+
let screenFrame = screen.visibleFrame
189+
let windowSize = window.frame.size
190+
let padding: CGFloat = 20
191+
192+
let origin =
193+
switch corner {
194+
case .topLeft:
195+
NSPoint(
196+
x: screenFrame.minX + padding,
197+
y: screenFrame.maxY - windowSize.height - padding
198+
)
199+
case .topRight:
200+
NSPoint(
201+
x: screenFrame.maxX - windowSize.width - padding,
202+
y: screenFrame.maxY - windowSize.height - padding
203+
)
204+
case .bottomLeft:
205+
NSPoint(
206+
x: screenFrame.minX + padding,
207+
y: screenFrame.minY + padding
208+
)
209+
case .bottomRight:
210+
NSPoint(
211+
x: screenFrame.maxX - windowSize.width - padding,
212+
y: screenFrame.minY + padding
213+
)
214+
}
215+
216+
window.setFrameOrigin(origin)
217+
}
218+
219+
private func nearestCorner(for window: NSWindow) -> Corner {
220+
guard let screen = NSScreen.main else { return .bottomRight }
221+
let screenFrame = screen.visibleFrame
222+
let windowCenter = NSPoint(
223+
x: window.frame.midX,
224+
y: window.frame.midY
225+
)
226+
let screenCenter = NSPoint(
227+
x: screenFrame.midX,
228+
y: screenFrame.midY
229+
)
230+
231+
let isLeft = windowCenter.x < screenCenter.x
232+
let isTop = windowCenter.y > screenCenter.y
233+
234+
switch (isLeft, isTop) {
235+
case (true, true): return .topLeft
236+
case (false, true): return .topRight
237+
case (true, false): return .bottomLeft
238+
case (false, false): return .bottomRight
239+
}
240+
}
241+
242+
private func saveCorner() {
243+
UserDefaults.standard.set(self.currentCorner.rawValue, forKey: "videoWindowCorner")
244+
}
245+
246+
private func loadCorner() {
247+
let raw = UserDefaults.standard.integer(forKey: "videoWindowCorner")
248+
self.currentCorner = Corner(rawValue: raw) ?? .bottomRight
249+
}
250+
}

Core/Models/MusicVideoType.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import Foundation
2+
3+
// MARK: - MusicVideoType
4+
5+
/// Represents the type of music video content from YouTube Music.
6+
///
7+
/// This enum maps to the `musicVideoType` field in the YouTube Music API's
8+
/// `player` and `next` endpoint responses. It helps distinguish between
9+
/// actual music videos (with video content) and audio-only tracks.
10+
///
11+
/// - Note: Only `.omv` (Official Music Video) should display the video toggle
12+
/// button, as other types have either static images or no meaningful video.
13+
enum MusicVideoType: String, Codable, Sendable {
14+
/// Official Music Video - Full video content from the artist/label.
15+
/// These have actual video content and should show the video toggle.
16+
case omv = "MUSIC_VIDEO_TYPE_OMV"
17+
18+
/// Audio Track Video - Static image or visualizer (audio only).
19+
/// These do NOT have meaningful video content.
20+
case atv = "MUSIC_VIDEO_TYPE_ATV"
21+
22+
/// User Generated Content - Fan-made or unofficial videos.
23+
case ugc = "MUSIC_VIDEO_TYPE_UGC"
24+
25+
/// Podcast Episode - Audio podcast content.
26+
case podcastEpisode = "MUSIC_VIDEO_TYPE_PODCAST_EPISODE"
27+
28+
/// Whether this video type has actual video content worth showing.
29+
/// Only Official Music Videos have meaningful video.
30+
var hasVideoContent: Bool {
31+
self == .omv
32+
}
33+
34+
/// Human-readable description of the video type.
35+
var displayName: String {
36+
switch self {
37+
case .omv: "Official Music Video"
38+
case .atv: "Audio Track"
39+
case .ugc: "User Generated"
40+
case .podcastEpisode: "Podcast Episode"
41+
}
42+
}
43+
}

Core/Models/Song.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ struct Song: Identifiable, Codable, Hashable, Sendable {
1212
let thumbnailURL: URL?
1313
let videoId: String
1414

15+
/// Whether this track has a music video available.
16+
var hasVideo: Bool?
17+
18+
/// The type of music video (OMV, ATV, UGC, etc.).
19+
/// Use `musicVideoType?.hasVideoContent` to check if video is worth displaying.
20+
var musicVideoType: MusicVideoType?
21+
1522
/// Like/dislike status of the song (nil if unknown).
1623
var likeStatus: LikeStatus?
1724

@@ -30,6 +37,8 @@ struct Song: Identifiable, Codable, Hashable, Sendable {
3037
duration: TimeInterval? = nil,
3138
thumbnailURL: URL? = nil,
3239
videoId: String,
40+
hasVideo: Bool? = nil,
41+
musicVideoType: MusicVideoType? = nil,
3342
likeStatus: LikeStatus? = nil,
3443
isInLibrary: Bool? = nil,
3544
feedbackTokens: FeedbackTokens? = nil
@@ -41,6 +50,8 @@ struct Song: Identifiable, Codable, Hashable, Sendable {
4150
self.duration = duration
4251
self.thumbnailURL = thumbnailURL
4352
self.videoId = videoId
53+
self.hasVideo = hasVideo
54+
self.musicVideoType = musicVideoType
4455
self.likeStatus = likeStatus
4556
self.isInLibrary = isInLibrary
4657
self.feedbackTokens = feedbackTokens

Core/Services/API/Parsers/SongMetadataParser.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ enum SongMetadataParser {
2323
let thumbnailURL = self.parseThumbnail(from: panelVideoRenderer)
2424
let duration = self.parseDuration(from: panelVideoRenderer)
2525
let menuData = self.parseMenuData(from: panelVideoRenderer)
26+
let musicVideoType = self.parseMusicVideoType(from: panelVideoRenderer)
2627

2728
return Song(
2829
id: videoId,
@@ -32,6 +33,7 @@ enum SongMetadataParser {
3233
duration: duration,
3334
thumbnailURL: thumbnailURL,
3435
videoId: videoId,
36+
musicVideoType: musicVideoType,
3537
likeStatus: menuData.likeStatus,
3638
isInLibrary: menuData.isInLibrary,
3739
feedbackTokens: menuData.feedbackTokens
@@ -133,6 +135,19 @@ enum SongMetadataParser {
133135
return ParsingHelpers.parseDuration(text)
134136
}
135137

138+
/// Parses the music video type from the panel video renderer's navigation endpoint.
139+
/// Path: navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
140+
static func parseMusicVideoType(from renderer: [String: Any]) -> MusicVideoType? {
141+
guard let navEndpoint = renderer["navigationEndpoint"] as? [String: Any],
142+
let watchEndpoint = navEndpoint["watchEndpoint"] as? [String: Any],
143+
let configs = watchEndpoint["watchEndpointMusicSupportedConfigs"] as? [String: Any],
144+
let musicConfig = configs["watchEndpointMusicConfig"] as? [String: Any],
145+
let typeString = musicConfig["musicVideoType"] as? String
146+
else { return nil }
147+
148+
return MusicVideoType(rawValue: typeString)
149+
}
150+
136151
/// Parses menu data (feedbackTokens, library status, like status) from the panel video renderer.
137152
static func parseMenuData(from renderer: [String: Any]) -> MenuParseResult {
138153
var result = MenuParseResult(feedbackTokens: nil, isInLibrary: false, likeStatus: .indifferent)

0 commit comments

Comments
 (0)