Skip to content

Commit f44673a

Browse files
committed
lint and update readme
Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
1 parent 8f76e29 commit f44673a

File tree

4 files changed

+150
-117
lines changed

4 files changed

+150
-117
lines changed

Core/Services/API/Parsers/HomeResponseParser.swift

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -483,42 +483,52 @@ enum HomeResponseParser {
483483
return nil
484484
}
485485

486+
private enum BrowseItemType {
487+
case album
488+
case playlist
489+
case artist
490+
}
491+
492+
private static func determineBrowseItemType(browseId: String, pageType: String?) -> BrowseItemType? {
493+
// Check pageType first (most reliable)
494+
switch pageType {
495+
case "MUSIC_PAGE_TYPE_ALBUM":
496+
return .album
497+
case "MUSIC_PAGE_TYPE_PLAYLIST":
498+
return .playlist
499+
case "MUSIC_PAGE_TYPE_ARTIST", "MUSIC_PAGE_TYPE_USER_CHANNEL":
500+
return .artist
501+
default:
502+
break
503+
}
504+
505+
// Fall back to browseId prefix
506+
if browseId.hasPrefix("MPRE") || browseId.hasPrefix("OLAK") {
507+
return .album
508+
}
509+
if browseId.hasPrefix("VL") || browseId.hasPrefix("PL") || browseId.hasPrefix("RD") {
510+
return .playlist
511+
}
512+
if browseId.hasPrefix("UC") {
513+
return .artist
514+
}
515+
516+
return nil
517+
}
518+
486519
private static func createItemFromBrowseEndpoint(
487520
browseId: String,
488521
pageType: String?,
489522
title: String,
490523
thumbnailURL: URL?,
491524
data: [String: Any]
492525
) -> HomeSectionItem? {
493-
// Determine type based on pageType first, then fall back to browseId prefix
494-
if pageType == "MUSIC_PAGE_TYPE_ALBUM" {
495-
let album = Album(
496-
id: browseId,
497-
title: title,
498-
artists: ParsingHelpers.extractArtists(from: data),
499-
thumbnailURL: thumbnailURL,
500-
year: nil,
501-
trackCount: nil
502-
)
503-
return .album(album)
504-
} else if pageType == "MUSIC_PAGE_TYPE_PLAYLIST" {
505-
let playlist = Playlist(
506-
id: browseId,
507-
title: title,
508-
description: nil,
509-
thumbnailURL: thumbnailURL,
510-
trackCount: nil,
511-
author: ParsingHelpers.extractSubtitle(from: data)
512-
)
513-
return .playlist(playlist)
514-
} else if pageType == "MUSIC_PAGE_TYPE_ARTIST" || pageType == "MUSIC_PAGE_TYPE_USER_CHANNEL" {
515-
let artist = Artist(
516-
id: browseId,
517-
name: title,
518-
thumbnailURL: thumbnailURL
519-
)
520-
return .artist(artist)
521-
} else if browseId.hasPrefix("MPRE") || browseId.hasPrefix("OLAK") {
526+
guard let itemType = determineBrowseItemType(browseId: browseId, pageType: pageType) else {
527+
return nil
528+
}
529+
530+
switch itemType {
531+
case .album:
522532
let album = Album(
523533
id: browseId,
524534
title: title,
@@ -528,7 +538,8 @@ enum HomeResponseParser {
528538
trackCount: nil
529539
)
530540
return .album(album)
531-
} else if browseId.hasPrefix("VL") || browseId.hasPrefix("PL") || browseId.hasPrefix("RD") {
541+
542+
case .playlist:
532543
let playlist = Playlist(
533544
id: browseId,
534545
title: title,
@@ -538,15 +549,14 @@ enum HomeResponseParser {
538549
author: ParsingHelpers.extractSubtitle(from: data)
539550
)
540551
return .playlist(playlist)
541-
} else if browseId.hasPrefix("UC") {
552+
553+
case .artist:
542554
let artist = Artist(
543555
id: browseId,
544556
name: title,
545557
thumbnailURL: thumbnailURL
546558
)
547559
return .artist(artist)
548560
}
549-
550-
return nil
551561
}
552562
}

Core/Services/API/Parsers/PlaylistParser.swift

Lines changed: 86 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -96,107 +96,111 @@ enum PlaylistParser {
9696
return header
9797
}
9898

99-
// Try musicDetailHeaderRenderer (most common for playlists)
100-
if let headerRenderer = headerDict["musicDetailHeaderRenderer"] as? [String: Any] {
101-
if let text = ParsingHelpers.extractTitle(from: headerRenderer) {
102-
header.title = text
103-
}
99+
// Try each header renderer type in order of preference
100+
applyDetailHeaderRenderer(from: headerDict, to: &header)
101+
applyImmersiveHeaderRenderer(from: headerDict, to: &header)
102+
applyVisualHeaderRenderer(from: headerDict, to: &header)
103+
applyEditablePlaylistHeaderRenderer(from: headerDict, to: &header)
104104

105-
if let descData = headerRenderer["description"] as? [String: Any],
106-
let runs = descData["runs"] as? [[String: Any]]
107-
{
108-
header.description = runs.compactMap { $0["text"] as? String }.joined()
109-
}
105+
return header
106+
}
110107

111-
let thumbnails = ParsingHelpers.extractThumbnails(from: headerRenderer)
112-
header.thumbnailURL = thumbnails.last.flatMap { URL(string: $0) }
108+
private static func applyDetailHeaderRenderer(from headerDict: [String: Any], to header: inout HeaderData) {
109+
guard let renderer = headerDict["musicDetailHeaderRenderer"] as? [String: Any] else { return }
113110

114-
if let subtitleData = headerRenderer["subtitle"] as? [String: Any],
115-
let runs = subtitleData["runs"] as? [[String: Any]]
116-
{
117-
let texts = runs.compactMap { $0["text"] as? String }
118-
header.author = texts.first
119-
}
111+
if let text = ParsingHelpers.extractTitle(from: renderer) {
112+
header.title = text
113+
}
120114

121-
if let secondSubtitleData = headerRenderer["secondSubtitle"] as? [String: Any],
122-
let runs = secondSubtitleData["runs"] as? [[String: Any]]
123-
{
124-
let texts = runs.compactMap { $0["text"] as? String }
125-
header.duration = texts.joined()
126-
}
115+
if let descData = renderer["description"] as? [String: Any],
116+
let runs = descData["runs"] as? [[String: Any]]
117+
{
118+
header.description = runs.compactMap { $0["text"] as? String }.joined()
127119
}
128120

129-
// Try musicImmersiveHeaderRenderer (used by some albums)
130-
if let immersiveHeader = headerDict["musicImmersiveHeaderRenderer"] as? [String: Any] {
131-
if header.title == "Unknown Playlist",
132-
let text = ParsingHelpers.extractTitle(from: immersiveHeader)
133-
{
134-
header.title = text
135-
}
121+
let thumbnails = ParsingHelpers.extractThumbnails(from: renderer)
122+
header.thumbnailURL = thumbnails.last.flatMap { URL(string: $0) }
136123

137-
// Always try to get thumbnail from immersive header if we don't have one
138-
if header.thumbnailURL == nil {
139-
let thumbnails = ParsingHelpers.extractThumbnails(from: immersiveHeader)
140-
header.thumbnailURL = thumbnails.last.flatMap { URL(string: $0) }
141-
}
124+
if let subtitleData = renderer["subtitle"] as? [String: Any],
125+
let runs = subtitleData["runs"] as? [[String: Any]]
126+
{
127+
header.author = runs.compactMap { $0["text"] as? String }.first
128+
}
142129

143-
if header.description == nil,
144-
let descData = immersiveHeader["description"] as? [String: Any],
145-
let runs = descData["runs"] as? [[String: Any]]
146-
{
147-
header.description = runs.compactMap { $0["text"] as? String }.joined()
148-
}
130+
if let secondSubtitleData = renderer["secondSubtitle"] as? [String: Any],
131+
let runs = secondSubtitleData["runs"] as? [[String: Any]]
132+
{
133+
header.duration = runs.compactMap { $0["text"] as? String }.joined()
134+
}
135+
}
149136

150-
// Try subtitle for author if not set
151-
if header.author == nil,
152-
let subtitleData = immersiveHeader["subtitle"] as? [String: Any],
153-
let runs = subtitleData["runs"] as? [[String: Any]]
154-
{
155-
let texts = runs.compactMap { $0["text"] as? String }
156-
header.author = texts.first
157-
}
137+
private static func applyImmersiveHeaderRenderer(from headerDict: [String: Any], to header: inout HeaderData) {
138+
guard let renderer = headerDict["musicImmersiveHeaderRenderer"] as? [String: Any] else { return }
139+
140+
if header.title == "Unknown Playlist",
141+
let text = ParsingHelpers.extractTitle(from: renderer)
142+
{
143+
header.title = text
158144
}
159145

160-
// Try musicVisualHeaderRenderer (alternative format for some albums/artists)
161-
if let visualHeader = headerDict["musicVisualHeaderRenderer"] as? [String: Any] {
162-
if header.title == "Unknown Playlist",
163-
let text = ParsingHelpers.extractTitle(from: visualHeader)
164-
{
165-
header.title = text
166-
}
146+
if header.thumbnailURL == nil {
147+
let thumbnails = ParsingHelpers.extractThumbnails(from: renderer)
148+
header.thumbnailURL = thumbnails.last.flatMap { URL(string: $0) }
149+
}
167150

168-
if header.thumbnailURL == nil {
169-
let thumbnails = ParsingHelpers.extractThumbnails(from: visualHeader)
170-
header.thumbnailURL = thumbnails.last.flatMap { URL(string: $0) }
171-
}
151+
if header.description == nil,
152+
let descData = renderer["description"] as? [String: Any],
153+
let runs = descData["runs"] as? [[String: Any]]
154+
{
155+
header.description = runs.compactMap { $0["text"] as? String }.joined()
172156
}
173157

174-
// Try musicEditablePlaylistDetailHeaderRenderer (for user-editable playlists)
175-
if let editableHeader = headerDict["musicEditablePlaylistDetailHeaderRenderer"] as? [String: Any],
176-
let nestedHeaderData = editableHeader["header"] as? [String: Any],
177-
let detailHeader = nestedHeaderData["musicDetailHeaderRenderer"] as? [String: Any]
158+
if header.author == nil,
159+
let subtitleData = renderer["subtitle"] as? [String: Any],
160+
let runs = subtitleData["runs"] as? [[String: Any]]
178161
{
179-
if header.title == "Unknown Playlist",
180-
let text = ParsingHelpers.extractTitle(from: detailHeader)
181-
{
182-
header.title = text
183-
}
162+
header.author = runs.compactMap { $0["text"] as? String }.first
163+
}
164+
}
184165

185-
if header.thumbnailURL == nil {
186-
let thumbnails = ParsingHelpers.extractThumbnails(from: detailHeader)
187-
header.thumbnailURL = thumbnails.last.flatMap { URL(string: $0) }
188-
}
166+
private static func applyVisualHeaderRenderer(from headerDict: [String: Any], to header: inout HeaderData) {
167+
guard let renderer = headerDict["musicVisualHeaderRenderer"] as? [String: Any] else { return }
189168

190-
if header.author == nil,
191-
let subtitleData = detailHeader["subtitle"] as? [String: Any],
192-
let runs = subtitleData["runs"] as? [[String: Any]]
193-
{
194-
let texts = runs.compactMap { $0["text"] as? String }
195-
header.author = texts.first
196-
}
169+
if header.title == "Unknown Playlist",
170+
let text = ParsingHelpers.extractTitle(from: renderer)
171+
{
172+
header.title = text
197173
}
198174

199-
return header
175+
if header.thumbnailURL == nil {
176+
let thumbnails = ParsingHelpers.extractThumbnails(from: renderer)
177+
header.thumbnailURL = thumbnails.last.flatMap { URL(string: $0) }
178+
}
179+
}
180+
181+
private static func applyEditablePlaylistHeaderRenderer(from headerDict: [String: Any], to header: inout HeaderData) {
182+
guard let editableHeader = headerDict["musicEditablePlaylistDetailHeaderRenderer"] as? [String: Any],
183+
let nestedHeaderData = editableHeader["header"] as? [String: Any],
184+
let detailHeader = nestedHeaderData["musicDetailHeaderRenderer"] as? [String: Any]
185+
else { return }
186+
187+
if header.title == "Unknown Playlist",
188+
let text = ParsingHelpers.extractTitle(from: detailHeader)
189+
{
190+
header.title = text
191+
}
192+
193+
if header.thumbnailURL == nil {
194+
let thumbnails = ParsingHelpers.extractThumbnails(from: detailHeader)
195+
header.thumbnailURL = thumbnails.last.flatMap { URL(string: $0) }
196+
}
197+
198+
if header.author == nil,
199+
let subtitleData = detailHeader["subtitle"] as? [String: Any],
200+
let runs = subtitleData["runs"] as? [[String: Any]]
201+
{
202+
header.author = runs.compactMap { $0["text"] as? String }.first
203+
}
200204
}
201205

202206
// MARK: - Track Parsing

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ A native macOS YouTube Music client built with Swift and SwiftUI.
99
- 🎵 **Native macOS Experience** — Apple Music-style UI with Liquid Glass player bar and clean sidebar navigation
1010
- 🎧 **YouTube Music Premium Support** — Full playback of DRM-protected content via your existing subscription
1111
- 🎛️ **System Integration** — Now Playing in Control Center, media key support, Dock menu controls
12+
- 📳 **Haptic Feedback** — Tactile feedback on Force Touch trackpads for player controls and navigation
1213
- 🎶 **Track Notifications** — Get notified when a new track starts playing
1314
- 🔊 **Background Audio** — Music continues playing when the window is closed; stops on quit
1415
- ⌨️ **Keyboard Shortcuts** — Full keyboard control for playback, navigation, and more

docs/architecture.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ Core/ → Shared logic (platform-independent)
1414
│ ├── API/ → YTMusicClient, Parsers/
1515
│ ├── Auth/ → AuthService (login state machine)
1616
│ ├── Player/ → PlayerService, NowPlayingManager (media keys)
17-
│ └── WebKit/ → WebKitManager (cookie persistence)
17+
│ ├── WebKit/ → WebKitManager (cookie persistence)
18+
│ └── HapticService.swift → Force Touch trackpad haptic feedback
1819
├── ViewModels/ → State management (HomeViewModel, etc.)
1920
└── Utilities/ → Helpers (DiagnosticsLogger, extensions)
2021
Views/
@@ -190,6 +191,23 @@ Remote command center integration for media key support:
190191

191192
**Note**: Now Playing display (track info, album art) is handled natively by WKWebView's Media Session API. This provides better integration with album artwork from YouTube Music.
192193

194+
### HapticService
195+
196+
**File**: `Core/Services/HapticService.swift`
197+
198+
Provides tactile feedback on Macs with Force Touch trackpads:
199+
200+
| Feedback Type | Pattern | Used For |
201+
|---------------|---------|----------|
202+
| `.playbackAction` | `.generic` | Play, pause, skip |
203+
| `.toggle` | `.alignment` | Shuffle, repeat, like/dislike |
204+
| `.sliderBoundary` | `.levelChange` | Volume/seek at 0% or 100% |
205+
| `.navigation` | `.alignment` | Sidebar selection |
206+
| `.success` | `.generic` | Add to library, search submit |
207+
| `.error` | `.generic` | Action failures |
208+
209+
**Accessibility**: Respects user preference (Settings → General) and system "Reduce Motion" setting.
210+
193211
### AppDelegate
194212

195213
**File**: `App/AppDelegate.swift`
@@ -390,7 +408,7 @@ DiagnosticsLogger.player.info("Loading video: \(videoId)")
390408
DiagnosticsLogger.auth.error("Cookie extraction failed")
391409
```
392410

393-
**Categories**: `.player`, `.auth`, `.api`, `.webKit`
411+
**Categories**: `.player`, `.auth`, `.api`, `.webKit`, `.haptic`
394412

395413
**Levels**: `.debug`, `.info`, `.warning`, `.error`
396414

0 commit comments

Comments
 (0)