Skip to content

Commit a7f7cee

Browse files
authored
share (#8)
1 parent e415f00 commit a7f7cee

20 files changed

+926
-35
lines changed

App/Info.plist

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,34 @@
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
44
<dict>
5+
<!-- URL Scheme Registration -->
6+
<key>CFBundleURLTypes</key>
7+
<array>
8+
<dict>
9+
<key>CFBundleURLName</key>
10+
<string>com.sertacozercan.kaset</string>
11+
<key>CFBundleURLSchemes</key>
12+
<array>
13+
<string>kaset</string>
14+
</array>
15+
<key>CFBundleTypeRole</key>
16+
<string>Viewer</string>
17+
</dict>
18+
</array>
19+
520
<!-- Sparkle Auto-Update Configuration -->
621
<key>SUFeedURL</key>
722
<string>https://raw.githubusercontent.com/sozercan/kaset/main/appcast.xml</string>
8-
23+
924
<key>SUPublicEDKey</key>
1025
<string>qa2zoeXHqn+pluxQSGjn5HyIYA/iFtrEJz7S1BoslpI=</string>
11-
26+
1227
<key>SUEnableAutomaticChecks</key>
1328
<true/>
14-
29+
1530
<key>SUScheduledCheckInterval</key>
1631
<integer>86400</integer>
17-
32+
1833
<key>SUAllowsAutomaticUpdates</key>
1934
<true/>
2035
</dict>

App/KasetApp.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ struct KasetApp: App {
121121
// Warm up Foundation Models in background
122122
await FoundationModelsService.shared.warmup()
123123
}
124+
.onOpenURL { url in
125+
self.handleIncomingURL(url)
126+
}
124127
}
125128
}
126129

@@ -271,6 +274,47 @@ struct KasetApp: App {
271274
"Repeat Off"
272275
}
273276
}
277+
278+
// MARK: - URL Handling
279+
280+
/// Handles an incoming URL (from custom scheme).
281+
private func handleIncomingURL(_ url: URL) {
282+
DiagnosticsLogger.app.info("Received URL: \(url.absoluteString)")
283+
284+
guard let content = URLHandler.parse(url) else {
285+
DiagnosticsLogger.app.warning("Unrecognized URL format: \(url.absoluteString)")
286+
return
287+
}
288+
289+
// If not logged in, ignore for now
290+
guard self.authService.state.isLoggedIn else {
291+
DiagnosticsLogger.app.info("Not logged in, ignoring URL")
292+
return
293+
}
294+
295+
self.handleParsedContent(content)
296+
}
297+
298+
/// Handles parsed URL content.
299+
private func handleParsedContent(_ content: URLHandler.ParsedContent) {
300+
switch content {
301+
case let .song(videoId):
302+
DiagnosticsLogger.app.info("Playing song from URL: \(videoId)")
303+
let song = Song(
304+
id: videoId,
305+
title: "Loading...",
306+
artists: [],
307+
videoId: videoId
308+
)
309+
Task {
310+
await self.playerService.play(song: song)
311+
}
312+
313+
case .playlist, .album, .artist:
314+
// Only song playback is supported via URL scheme
315+
DiagnosticsLogger.app.info("URL scheme only supports song playback")
316+
}
317+
}
274318
}
275319

276320
// MARK: - SettingsView

Core/Services/ShareService.swift

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import Foundation
2+
3+
// MARK: - Shareable
4+
5+
/// Protocol for items that can be shared with a URL and formatted text.
6+
protocol Shareable {
7+
/// The title to display in share text.
8+
var shareTitle: String { get }
9+
10+
/// Optional subtitle (e.g., artist/author). Nil for artists.
11+
var shareSubtitle: String? { get }
12+
13+
/// The YouTube Music URL for sharing. Nil if not shareable.
14+
var shareURL: URL? { get }
15+
}
16+
17+
extension Shareable {
18+
/// Formatted share text: "Title by Artist" or just "Title" if no subtitle.
19+
var shareText: String {
20+
if let subtitle = shareSubtitle, !subtitle.isEmpty {
21+
return "\(shareTitle) by \(subtitle)"
22+
}
23+
return shareTitle
24+
}
25+
}
26+
27+
// MARK: - Song + Shareable
28+
29+
extension Song: Shareable {
30+
var shareTitle: String {
31+
self.title
32+
}
33+
34+
var shareSubtitle: String? {
35+
self.artistsDisplay
36+
}
37+
38+
var shareURL: URL? {
39+
guard let encodedId = self.videoId.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
40+
return nil
41+
}
42+
return URL(string: "https://music.youtube.com/watch?v=\(encodedId)")
43+
}
44+
}
45+
46+
// MARK: - Playlist + Shareable
47+
48+
extension Playlist: Shareable {
49+
var shareTitle: String {
50+
self.title
51+
}
52+
53+
var shareSubtitle: String? {
54+
self.author
55+
}
56+
57+
var shareURL: URL? {
58+
guard let encodedId = self.id.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
59+
return nil
60+
}
61+
return URL(string: "https://music.youtube.com/playlist?list=\(encodedId)")
62+
}
63+
}
64+
65+
// MARK: - Album + Shareable
66+
67+
extension Album: Shareable {
68+
var shareTitle: String {
69+
self.title
70+
}
71+
72+
var shareSubtitle: String? {
73+
self.artistsDisplay
74+
}
75+
76+
var shareURL: URL? {
77+
// Only albums with navigable IDs (MPRE or OLAK prefixes) can be shared
78+
guard self.hasNavigableId else { return nil }
79+
guard let encodedId = self.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
80+
return nil
81+
}
82+
return URL(string: "https://music.youtube.com/browse/\(encodedId)")
83+
}
84+
}
85+
86+
// MARK: - Artist + Shareable
87+
88+
extension Artist: Shareable {
89+
var shareTitle: String {
90+
self.name
91+
}
92+
93+
var shareSubtitle: String? {
94+
nil // Artists don't have a subtitle
95+
}
96+
97+
var shareURL: URL? {
98+
// Valid artist IDs start with "UC" and don't contain hyphens (UUIDs)
99+
guard self.id.hasPrefix("UC"), !self.id.contains("-") else { return nil }
100+
guard let encodedId = self.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
101+
return nil
102+
}
103+
return URL(string: "https://music.youtube.com/channel/\(encodedId)")
104+
}
105+
}

Core/Services/URLHandler.swift

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import Foundation
2+
3+
// MARK: - URLHandler
4+
5+
/// Handles parsing and routing of YouTube Music URLs.
6+
///
7+
/// Supports URLs like:
8+
/// - `https://music.youtube.com/watch?v=dQw4w9WgXcQ` - Play song
9+
/// - `https://music.youtube.com/playlist?list=PLxxx` - Open playlist
10+
/// - `https://music.youtube.com/browse/MPRExxx` - Open album
11+
/// - `https://music.youtube.com/browse/VLPLxxx` - Open playlist (browse format)
12+
/// - `https://music.youtube.com/channel/UCxxx` - Open artist
13+
/// - `kaset://play?v=dQw4w9WgXcQ` - Custom scheme for song
14+
/// - `kaset://playlist?list=PLxxx` - Custom scheme for playlist
15+
/// - `kaset://album?id=MPRExxx` - Custom scheme for album
16+
/// - `kaset://artist?id=UCxxx` - Custom scheme for artist
17+
enum URLHandler {
18+
// MARK: - Types
19+
20+
/// Represents the type of content from a parsed URL.
21+
enum ParsedContent: Sendable, Equatable {
22+
/// A song/video to play.
23+
case song(videoId: String)
24+
25+
/// A playlist to open.
26+
case playlist(id: String)
27+
28+
/// An album to open.
29+
case album(id: String)
30+
31+
/// An artist/channel to open.
32+
case artist(id: String)
33+
}
34+
35+
// MARK: - URL Parsing
36+
37+
/// Parses a YouTube Music URL and returns the content type.
38+
/// - Parameter url: The URL to parse.
39+
/// - Returns: The parsed content, or nil if the URL is not recognized.
40+
static func parse(_ url: URL) -> ParsedContent? {
41+
// Handle custom scheme
42+
if url.scheme == "kaset" {
43+
return self.parseKasetURL(url)
44+
}
45+
46+
// Handle YouTube Music web URLs
47+
if self.isYouTubeMusicURL(url) {
48+
return self.parseYouTubeMusicURL(url)
49+
}
50+
51+
return nil
52+
}
53+
54+
/// Checks if a URL is a YouTube Music URL.
55+
private static func isYouTubeMusicURL(_ url: URL) -> Bool {
56+
guard let host = url.host?.lowercased() else { return false }
57+
return host == "music.youtube.com" || host == "www.music.youtube.com"
58+
}
59+
60+
/// Parses a kaset:// custom scheme URL.
61+
private static func parseKasetURL(_ url: URL) -> ParsedContent? {
62+
guard url.scheme == "kaset" else { return nil }
63+
64+
let host = url.host?.lowercased() ?? ""
65+
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
66+
let queryItems = components?.queryItems ?? []
67+
68+
switch host {
69+
case "play":
70+
// kaset://play?v=videoId
71+
if let videoId = Self.queryValue(for: "v", in: queryItems), !videoId.isEmpty {
72+
return .song(videoId: videoId)
73+
}
74+
75+
case "playlist":
76+
// kaset://playlist?list=playlistId
77+
if let listId = Self.queryValue(for: "list", in: queryItems), !listId.isEmpty {
78+
return .playlist(id: listId)
79+
}
80+
81+
case "album":
82+
// kaset://album?id=albumId
83+
if let albumId = Self.queryValue(for: "id", in: queryItems), !albumId.isEmpty {
84+
return .album(id: albumId)
85+
}
86+
87+
case "artist":
88+
// kaset://artist?id=artistId
89+
if let artistId = Self.queryValue(for: "id", in: queryItems), !artistId.isEmpty {
90+
return .artist(id: artistId)
91+
}
92+
93+
default:
94+
break
95+
}
96+
97+
return nil
98+
}
99+
100+
/// Parses a music.youtube.com URL.
101+
private static func parseYouTubeMusicURL(_ url: URL) -> ParsedContent? {
102+
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
103+
let queryItems = components?.queryItems ?? []
104+
let path = url.path
105+
let pathLower = path.lowercased()
106+
107+
// /watch?v=videoId - Play song
108+
if pathLower == "/watch" || pathLower.hasPrefix("/watch") {
109+
if let videoId = Self.queryValue(for: "v", in: queryItems), !videoId.isEmpty {
110+
return .song(videoId: videoId)
111+
}
112+
}
113+
114+
// /playlist?list=playlistId - Open playlist
115+
if pathLower == "/playlist" || pathLower.hasPrefix("/playlist") {
116+
if let listId = Self.queryValue(for: "list", in: queryItems), !listId.isEmpty {
117+
return .playlist(id: listId)
118+
}
119+
}
120+
121+
// /browse/XXX - Album, Playlist (VLPL prefix), or other browse content
122+
if pathLower.hasPrefix("/browse/") {
123+
// Extract browseId preserving original case
124+
let browseId = String(path.dropFirst("/browse/".count))
125+
if !browseId.isEmpty {
126+
// VLPL prefix indicates a playlist in browse format
127+
if browseId.hasPrefix("VLPL") {
128+
// Convert VLPL... to PL... for playlist ID
129+
let playlistId = String(browseId.dropFirst(2))
130+
return .playlist(id: playlistId)
131+
}
132+
// MPRE or OLAK prefix indicates an album
133+
if browseId.hasPrefix("MPRE") || browseId.hasPrefix("OLAK") {
134+
return .album(id: browseId)
135+
}
136+
// UC prefix indicates a channel/artist
137+
if browseId.hasPrefix("UC") {
138+
return .artist(id: browseId)
139+
}
140+
// Other browse IDs could be albums or playlists
141+
// Default to treating as album since it's under /browse
142+
return .album(id: browseId)
143+
}
144+
}
145+
146+
// /channel/UCxxx - Open artist
147+
if pathLower.hasPrefix("/channel/") {
148+
// Extract channelId preserving original case
149+
let channelId = String(path.dropFirst("/channel/".count))
150+
if !channelId.isEmpty {
151+
return .artist(id: channelId)
152+
}
153+
}
154+
155+
return nil
156+
}
157+
158+
/// Gets a query parameter value.
159+
private static func queryValue(for name: String, in items: [URLQueryItem]) -> String? {
160+
items.first { $0.name == name }?.value
161+
}
162+
}

Core/Utilities/DiagnosticsLogger.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,7 @@ enum DiagnosticsLogger {
3232

3333
/// Logger for updater/general app events.
3434
static let updater = Logger(subsystem: "com.sertacozercan.Kaset", category: "Updater")
35+
36+
/// Logger for app lifecycle and URL handling events.
37+
static let app = Logger(subsystem: "com.sertacozercan.Kaset", category: "App")
3538
}

0 commit comments

Comments
 (0)