Fix 1 — Command routing:
All sendMessage calls now use replyHandler (even { _ in }) so
WCSession routes them to didReceiveMessage:replyHandler: where
the actual command handling lives. Previously next/prev/seek/volume
silently went to the fire-and-forget handler which dropped them.
Fix 2 — Seek bar:
Replaced DragGesture with onTapGesture for reliable watchOS taps.
Hit target increased from 3pt to 20pt. Both local and remote views.
Fix 3 — Volume:
Replaced tiny slider bar with speaker.minus/speaker.plus buttons.
Crown still works for fine control. Both local and remote views.
605 lines
23 KiB
Swift
605 lines
23 KiB
Swift
import Foundation
|
|
import WatchConnectivity
|
|
import Combine
|
|
|
|
/// watchOS-side WatchConnectivity session manager
|
|
class WatchSessionManager: NSObject, ObservableObject {
|
|
|
|
static let shared = WatchSessionManager()
|
|
|
|
@Published var servers: [ServerConfig] = []
|
|
@Published var activeServer: ServerConfig?
|
|
@Published var isPhoneReachable = false
|
|
@Published var isSyncing = false
|
|
|
|
// Now playing from iPhone
|
|
@Published var phoneNowPlaying: PhoneNowPlaying?
|
|
|
|
struct PhoneNowPlaying {
|
|
var title: String
|
|
var artist: String
|
|
var album: String
|
|
var isPlaying: Bool
|
|
var currentTime: TimeInterval
|
|
var duration: TimeInterval
|
|
var coverArtId: String?
|
|
var shuffleEnabled: Bool = false
|
|
var repeatMode: Int = 0 // 0=off, 1=all, 2=one
|
|
var volume: Float = 1.0
|
|
}
|
|
|
|
private var session: WCSession?
|
|
private let client = SubsonicClient()
|
|
|
|
/// Parse PhoneNowPlaying from a dictionary (reused by message handler + reply handler)
|
|
static func parseNowPlaying(from dict: [String: Any]) -> PhoneNowPlaying {
|
|
PhoneNowPlaying(
|
|
title: dict["title"] as? String ?? "",
|
|
artist: dict["artist"] as? String ?? "",
|
|
album: dict["album"] as? String ?? "",
|
|
isPlaying: dict["isPlaying"] as? Bool ?? false,
|
|
currentTime: dict["currentTime"] as? TimeInterval ?? 0,
|
|
duration: dict["duration"] as? TimeInterval ?? 0,
|
|
coverArtId: dict["coverArtId"] as? String,
|
|
shuffleEnabled: dict["shuffleEnabled"] as? Bool ?? false,
|
|
repeatMode: dict["repeatMode"] as? Int ?? 0,
|
|
volume: (dict["volume"] as? Double).map { Float($0) } ?? 1.0
|
|
)
|
|
}
|
|
|
|
private override init() {
|
|
super.init()
|
|
if WCSession.isSupported() {
|
|
session = WCSession.default
|
|
session?.delegate = self
|
|
session?.activate()
|
|
}
|
|
loadLocalServers()
|
|
}
|
|
|
|
// MARK: - Server Management
|
|
|
|
func addServer(_ server: ServerConfig) {
|
|
servers.append(server)
|
|
saveLocalServers()
|
|
}
|
|
|
|
func setActive(_ server: ServerConfig) {
|
|
activeServer = server
|
|
client.currentServer = server
|
|
}
|
|
|
|
func testConnection(_ server: ServerConfig) async -> Bool {
|
|
do {
|
|
return try await client.ping(server: server)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Request data from phone
|
|
|
|
func requestServersFromPhone() {
|
|
guard let session = session, session.isReachable else { return }
|
|
session.sendMessage(["type": "requestServers"], replyHandler: { reply in
|
|
if let data = reply["servers"] as? Data,
|
|
let servers = try? JSONDecoder().decode([ServerConfig].self, from: data) {
|
|
DispatchQueue.main.async {
|
|
self.servers = servers
|
|
self.activeServer = servers.first
|
|
if let first = servers.first {
|
|
self.client.currentServer = first
|
|
}
|
|
self.saveLocalServers()
|
|
}
|
|
}
|
|
}, errorHandler: { error in
|
|
print("Request servers failed: \(error)")
|
|
})
|
|
}
|
|
|
|
func sendPlayCommand() {
|
|
session?.sendMessage(["type": "playCommand"], replyHandler: { [weak self] reply in
|
|
if let isPlaying = reply["isPlaying"] as? Bool {
|
|
DispatchQueue.main.async {
|
|
guard let self else { return }
|
|
self.phoneNowPlaying?.isPlaying = isPlaying
|
|
}
|
|
}
|
|
}, errorHandler: nil)
|
|
}
|
|
|
|
func sendNextCommand() {
|
|
session?.sendMessage(["type": "nextCommand"], replyHandler: { _ in }, errorHandler: nil)
|
|
}
|
|
|
|
func sendPreviousCommand() {
|
|
session?.sendMessage(["type": "previousCommand"], replyHandler: { _ in }, errorHandler: nil)
|
|
}
|
|
|
|
func sendSeekCommand(to time: TimeInterval) {
|
|
session?.sendMessage(["type": "seekCommand", "time": time], replyHandler: { _ in }, errorHandler: nil)
|
|
}
|
|
|
|
func sendShuffleCommand() {
|
|
session?.sendMessage(["type": "shuffleCommand"], replyHandler: { [weak self] reply in
|
|
if let enabled = reply["shuffleEnabled"] as? Bool {
|
|
DispatchQueue.main.async {
|
|
guard let self else { return }
|
|
self.phoneNowPlaying?.shuffleEnabled = enabled
|
|
}
|
|
}
|
|
}, errorHandler: nil)
|
|
}
|
|
|
|
func sendRepeatCommand() {
|
|
session?.sendMessage(["type": "repeatCommand"], replyHandler: { [weak self] reply in
|
|
if let mode = reply["repeatMode"] as? Int {
|
|
DispatchQueue.main.async {
|
|
guard let self else { return }
|
|
self.phoneNowPlaying?.repeatMode = mode
|
|
}
|
|
}
|
|
}, errorHandler: nil)
|
|
}
|
|
|
|
func sendVolumeCommand(_ volume: Float) {
|
|
session?.sendMessage(["type": "volumeCommand", "volume": volume], replyHandler: { _ in }, errorHandler: nil)
|
|
}
|
|
|
|
/// Ask iPhone for current playback state (used on watch app launch)
|
|
func requestNowPlaying() {
|
|
guard let session = session, session.isReachable else { return }
|
|
session.sendMessage(["type": "requestNowPlaying"], replyHandler: { reply in
|
|
guard let title = reply["title"] as? String, !title.isEmpty else {
|
|
DispatchQueue.main.async { self.phoneNowPlaying = nil }
|
|
return
|
|
}
|
|
DispatchQueue.main.async {
|
|
self.phoneNowPlaying = Self.parseNowPlaying(from: reply)
|
|
}
|
|
}, errorHandler: { _ in })
|
|
}
|
|
|
|
func requestDownload(songId: String) {
|
|
session?.sendMessage(["type": "requestDownload", "songId": songId], replyHandler: { _ in }, errorHandler: nil)
|
|
}
|
|
|
|
// MARK: - Direct API Access (watch can also talk to server directly)
|
|
|
|
func getArtists() async throws -> [ArtistIndex] {
|
|
guard activeServer != nil else { return [] }
|
|
return try await client.getArtists()
|
|
}
|
|
|
|
func getArtist(id: String) async throws -> ArtistWithAlbums? {
|
|
return try await client.getArtist(id: id)
|
|
}
|
|
|
|
func getGenres() async throws -> [Genre] {
|
|
guard activeServer != nil else { return [] }
|
|
return try await client.getGenres()
|
|
}
|
|
|
|
func getAlbumList(type: String = "newest", size: Int = 20, offset: Int = 0) async throws -> [Album] {
|
|
guard activeServer != nil else { return [] }
|
|
return try await client.getAlbumList2(type: type, size: size, offset: offset)
|
|
}
|
|
|
|
/// Fetch ALL albums (paginated until exhausted)
|
|
func getAllAlbums() async throws -> [Album] {
|
|
guard activeServer != nil else { return [] }
|
|
var all: [Album] = []
|
|
var offset = 0
|
|
let pageSize = 500
|
|
while true {
|
|
let page = try await client.getAlbumList2(type: "alphabeticalByName", size: pageSize, offset: offset)
|
|
all.append(contentsOf: page)
|
|
if page.count < pageSize { break }
|
|
offset += pageSize
|
|
}
|
|
return all
|
|
}
|
|
|
|
func getAlbum(id: String) async throws -> AlbumWithSongs? {
|
|
return try await client.getAlbum(id: id)
|
|
}
|
|
|
|
func getAlbumsByGenre(_ genre: String) async throws -> [Album] {
|
|
guard activeServer != nil else { return [] }
|
|
return try await client.getAlbumList2(type: "byGenre", size: 500, genre: genre)
|
|
}
|
|
|
|
func getPlaylists() async throws -> [Playlist] {
|
|
return try await client.getPlaylists()
|
|
}
|
|
|
|
func getPlaylist(id: String) async throws -> PlaylistWithSongs? {
|
|
return try await client.getPlaylist(id: id)
|
|
}
|
|
|
|
func search(query: String) async throws -> SearchResult3? {
|
|
return try await client.search3(query: query)
|
|
}
|
|
|
|
func streamURL(songId: String, maxBitRate: Int? = 192) -> URL? {
|
|
return client.streamURL(songId: songId, format: maxBitRate != nil ? "mp3" : nil, maxBitRate: maxBitRate)
|
|
}
|
|
|
|
func coverArtURL(id: String, size: Int = 100) -> URL? {
|
|
return client.coverArtURL(id: id, size: size)
|
|
}
|
|
|
|
/// Downloads a song at 192kbps MP3 for watch storage efficiency
|
|
func downloadSong(id: String) async throws -> (Data, URLResponse) {
|
|
let params = [
|
|
URLQueryItem(name: "id", value: id),
|
|
URLQueryItem(name: "format", value: "mp3"),
|
|
URLQueryItem(name: "maxBitRate", value: "192")
|
|
]
|
|
return try await client.requestData(endpoint: "stream", params: params)
|
|
}
|
|
|
|
// MARK: - Watch Library Cache (UserDefaults, persists across launches)
|
|
|
|
private let cachePrefix = "wcache_"
|
|
|
|
func cacheEncode<T: Encodable>(_ data: T, key: String) {
|
|
if let encoded = try? JSONEncoder().encode(data) {
|
|
UserDefaults.standard.set(encoded, forKey: cachePrefix + key)
|
|
}
|
|
}
|
|
|
|
func cacheDecode<T: Decodable>(_ type: T.Type, key: String) -> T? {
|
|
guard let data = UserDefaults.standard.data(forKey: cachePrefix + key) else { return nil }
|
|
return try? JSONDecoder().decode(type, from: data)
|
|
}
|
|
|
|
/// Fetch with cache-first pattern: returns cached immediately, refreshes in background
|
|
func cachedArtists() -> [ArtistIndex] {
|
|
return cacheDecode([ArtistIndex].self, key: "artists") ?? []
|
|
}
|
|
|
|
func cachedGenres() -> [Genre] {
|
|
return cacheDecode([Genre].self, key: "genres") ?? []
|
|
}
|
|
|
|
func cachedAlbums() -> [Album] {
|
|
return cacheDecode([Album].self, key: "all_albums") ?? []
|
|
}
|
|
|
|
func cachedPlaylists() -> [Playlist] {
|
|
return cacheDecode([Playlist].self, key: "playlists") ?? []
|
|
}
|
|
|
|
func cachedAlbumDetail(id: String) -> AlbumWithSongs? {
|
|
return cacheDecode(AlbumWithSongs.self, key: "album_\(id)")
|
|
}
|
|
|
|
func cachedArtistDetail(id: String) -> ArtistWithAlbums? {
|
|
return cacheDecode(ArtistWithAlbums.self, key: "artist_\(id)")
|
|
}
|
|
|
|
/// Full sync — called on launch, caches everything
|
|
func syncLibrary() async {
|
|
guard activeServer != nil else { return }
|
|
await MainActor.run { isSyncing = true }
|
|
|
|
do {
|
|
let artists = try await getArtists()
|
|
cacheEncode(artists, key: "artists")
|
|
|
|
let genres = try await getGenres()
|
|
cacheEncode(genres, key: "genres")
|
|
|
|
let albums = try await getAllAlbums()
|
|
cacheEncode(albums, key: "all_albums")
|
|
|
|
let playlists = try await getPlaylists()
|
|
cacheEncode(playlists, key: "playlists")
|
|
|
|
let recent = try await getAlbumList(type: "newest", size: 30)
|
|
cacheEncode(recent, key: "recent")
|
|
|
|
print("[Watch] Library synced: \(artists.flatMap { $0.artist ?? [] }.count) artists, \(albums.count) albums, \(genres.count) genres")
|
|
} catch {
|
|
print("[Watch] Library sync failed: \(error.localizedDescription)")
|
|
}
|
|
|
|
await MainActor.run { isSyncing = false }
|
|
}
|
|
|
|
// MARK: - Tag Update Handler
|
|
|
|
private func handleTagsUpdated(_ message: [String: Any]) {
|
|
guard let songId = message["songId"] as? String else { return }
|
|
let title = message["title"] as? String
|
|
let artist = message["artist"] as? String
|
|
let album = message["album"] as? String
|
|
let albumArtist = message["albumArtist"] as? String
|
|
|
|
DispatchQueue.main.async {
|
|
// Update offline store if this song is downloaded on the watch
|
|
WatchOfflineStore.shared.updateSongMetadata(
|
|
id: songId, title: title, artist: artist,
|
|
album: album, albumArtist: albumArtist
|
|
)
|
|
// Invalidate stale library caches — next fetch will repopulate from server
|
|
for key in ["all_albums", "recent", "playlists"] {
|
|
UserDefaults.standard.removeObject(forKey: self.cachePrefix + key)
|
|
}
|
|
// Refresh now-playing display if this song is currently playing
|
|
if let t = title { self.phoneNowPlaying?.title = t }
|
|
if let a = artist { self.phoneNowPlaying?.artist = a }
|
|
if let b = album { self.phoneNowPlaying?.album = b }
|
|
print("[Watch] Tags updated for \(songId)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Server-Direct Download Handler
|
|
|
|
/// Called when iPhone tells us to download a song directly from server.
|
|
/// Watch downloads at 192kbps MP3 — no iPhone storage or transfer needed.
|
|
private func handleDownloadCommand(_ message: [String: Any]) {
|
|
guard let songId = message["songId"] as? String else {
|
|
print("[Watch] downloadSong: missing songId")
|
|
return
|
|
}
|
|
|
|
// Skip if already downloaded
|
|
if WatchOfflineStore.shared.isSongAvailable(songId) {
|
|
print("[Watch] Already downloaded: \(songId)")
|
|
return
|
|
}
|
|
|
|
let song = Song(
|
|
id: songId,
|
|
parent: nil, isDir: false,
|
|
title: message["title"] as? String ?? "Unknown",
|
|
album: message["album"] as? String,
|
|
artist: message["artist"] as? String,
|
|
albumArtist: message["albumArtist"] as? String,
|
|
track: nil, year: nil, genre: nil,
|
|
coverArt: message["coverArt"] as? String,
|
|
size: nil, contentType: nil,
|
|
suffix: message["suffix"] as? String ?? "mp3",
|
|
transcodedContentType: nil, transcodedSuffix: nil,
|
|
duration: message["duration"] as? Int,
|
|
bitRate: nil, path: nil, playCount: nil,
|
|
discNumber: nil, created: nil,
|
|
albumId: nil, artistId: nil,
|
|
type: nil, starred: nil, bpm: nil, musicBrainzId: nil
|
|
)
|
|
|
|
print("[Watch] Downloading from server: \(song.title)")
|
|
|
|
DispatchQueue.main.async {
|
|
WatchOfflineStore.shared.downloadFromServer(song: song)
|
|
}
|
|
}
|
|
|
|
// MARK: - Persistence
|
|
|
|
private func saveLocalServers() {
|
|
if let data = try? JSONEncoder().encode(servers) {
|
|
UserDefaults.standard.set(data, forKey: "watch_servers")
|
|
}
|
|
if let data = try? JSONEncoder().encode(activeServer) {
|
|
UserDefaults.standard.set(data, forKey: "watch_active_server")
|
|
}
|
|
}
|
|
|
|
private func loadLocalServers() {
|
|
if let data = UserDefaults.standard.data(forKey: "watch_servers"),
|
|
let decoded = try? JSONDecoder().decode([ServerConfig].self, from: data) {
|
|
servers = decoded
|
|
}
|
|
if let data = UserDefaults.standard.data(forKey: "watch_active_server"),
|
|
let decoded = try? JSONDecoder().decode(ServerConfig.self, from: data) {
|
|
activeServer = decoded
|
|
client.currentServer = decoded
|
|
} else if let first = servers.first {
|
|
activeServer = first
|
|
client.currentServer = first
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - WCSessionDelegate
|
|
extension WatchSessionManager: WCSessionDelegate {
|
|
|
|
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
|
DispatchQueue.main.async {
|
|
self.isPhoneReachable = session.isReachable
|
|
}
|
|
|
|
if activationState == .activated {
|
|
// Check existing application context (already sent before watch app opened)
|
|
let existingContext = session.receivedApplicationContext
|
|
if let data = existingContext["servers"] as? Data,
|
|
let decoded = try? JSONDecoder().decode([ServerConfig].self, from: data) {
|
|
DispatchQueue.main.async {
|
|
if self.servers.isEmpty {
|
|
self.servers = decoded
|
|
if self.activeServer == nil, let first = decoded.first {
|
|
self.activeServer = first
|
|
self.client.currentServer = first
|
|
}
|
|
self.saveLocalServers()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also proactively request from phone if reachable
|
|
if session.isReachable && self.servers.isEmpty {
|
|
self.requestServersFromPhone()
|
|
}
|
|
}
|
|
}
|
|
|
|
func sessionReachabilityDidChange(_ session: WCSession) {
|
|
DispatchQueue.main.async {
|
|
self.isPhoneReachable = session.isReachable
|
|
}
|
|
}
|
|
|
|
// Receive application context (server configs from iPhone)
|
|
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
|
if let data = applicationContext["servers"] as? Data,
|
|
let servers = try? JSONDecoder().decode([ServerConfig].self, from: data) {
|
|
DispatchQueue.main.async {
|
|
self.servers = servers
|
|
if self.activeServer == nil, let first = servers.first {
|
|
self.activeServer = first
|
|
self.client.currentServer = first
|
|
}
|
|
self.saveLocalServers()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Receive messages (now playing info + download commands from iPhone)
|
|
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
|
guard let type = message["type"] as? String else { return }
|
|
|
|
if type == "nowPlaying" {
|
|
DispatchQueue.main.async {
|
|
// If no title, iPhone stopped playing — clear remote state
|
|
guard let title = message["title"] as? String, !title.isEmpty else {
|
|
self.phoneNowPlaying = nil
|
|
return
|
|
}
|
|
self.phoneNowPlaying = Self.parseNowPlaying(from: message)
|
|
}
|
|
} else if type == "tagsUpdated" {
|
|
handleTagsUpdated(message)
|
|
} else if type == "downloadSong" {
|
|
handleDownloadCommand(message)
|
|
}
|
|
}
|
|
|
|
// Messages that need a reply
|
|
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
|
|
guard let type = message["type"] as? String else {
|
|
replyHandler(["error": "no type"])
|
|
return
|
|
}
|
|
|
|
switch type {
|
|
case "downloadSong":
|
|
handleDownloadCommand(message)
|
|
replyHandler(["status": "downloading"])
|
|
|
|
case "requestSongList":
|
|
let store = WatchOfflineStore.shared
|
|
struct WatchSongInfoEnc: Codable {
|
|
let id: String; let title: String; let artist: String; let album: String; let fileSize: Int64
|
|
}
|
|
let encoded = store.songs.map {
|
|
WatchSongInfoEnc(id: $0.id, title: $0.song.title, artist: $0.song.artist ?? "Unknown", album: $0.song.album ?? "Unknown", fileSize: $0.fileSize)
|
|
}
|
|
if let data = try? JSONEncoder().encode(encoded) {
|
|
replyHandler(["songs": data])
|
|
} else {
|
|
replyHandler(["songs": Data()])
|
|
}
|
|
print("[Watch] Sent song list: \(store.songs.count) songs")
|
|
|
|
case "deleteSong":
|
|
if let songId = message["songId"] as? String {
|
|
DispatchQueue.main.async {
|
|
WatchOfflineStore.shared.removeSong(songId)
|
|
}
|
|
print("[Watch] Deleted song: \(songId)")
|
|
replyHandler(["ok": true])
|
|
} else {
|
|
replyHandler(["error": "no songId"])
|
|
}
|
|
|
|
case "deleteAllSongs":
|
|
DispatchQueue.main.async {
|
|
WatchOfflineStore.shared.removeAll()
|
|
}
|
|
print("[Watch] Deleted all songs")
|
|
replyHandler(["ok": true])
|
|
|
|
default:
|
|
replyHandler(["error": "unknown type"])
|
|
}
|
|
}
|
|
|
|
// Receive user info (wake signals, queued download commands from phone)
|
|
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
|
if let type = userInfo["type"] as? String {
|
|
print("[Watch] Received userInfo: \(type)")
|
|
if type == "wake" {
|
|
print("[Watch] Wake signal received — app is active")
|
|
} else if type == "downloadSong" {
|
|
handleDownloadCommand(userInfo)
|
|
} else if type == "tagsUpdated" {
|
|
handleTagsUpdated(userInfo)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Receive files (downloaded songs from iPhone)
|
|
func session(_ session: WCSession, didReceive file: WCSessionFile) {
|
|
let metadata = file.metadata ?? [:]
|
|
print("[Watch] Received file transfer. Keys: \(metadata.keys.sorted())")
|
|
|
|
// Support both "songId" and "id" keys for compatibility
|
|
guard let songId = (metadata["songId"] as? String) ?? (metadata["id"] as? String),
|
|
let suffix = metadata["suffix"] as? String else {
|
|
print("[Watch] ERROR: Missing songId or suffix in metadata: \(metadata)")
|
|
return
|
|
}
|
|
|
|
print("[Watch] Processing song: \(songId), suffix: \(suffix)")
|
|
|
|
// Move file to permanent location
|
|
let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
|
let musicDir = documentsDir.appendingPathComponent("OfflineMusic", isDirectory: true)
|
|
try? FileManager.default.createDirectory(at: musicDir, withIntermediateDirectories: true)
|
|
|
|
let destURL = musicDir.appendingPathComponent("\(songId).\(suffix)")
|
|
|
|
do {
|
|
if FileManager.default.fileExists(atPath: destURL.path) {
|
|
try FileManager.default.removeItem(at: destURL)
|
|
}
|
|
try FileManager.default.moveItem(at: file.fileURL, to: destURL)
|
|
|
|
let fileSize = (try? FileManager.default.attributesOfItem(atPath: destURL.path)[.size] as? Int64) ?? 0
|
|
print("[Watch] Saved: \(destURL.lastPathComponent) (\(ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file)))")
|
|
|
|
// Support both key naming conventions
|
|
let coverArtId = (metadata["coverArtId"] as? String) ?? (metadata["coverArt"] as? String)
|
|
|
|
let song = Song(
|
|
id: songId,
|
|
parent: nil, isDir: false,
|
|
title: metadata["title"] as? String ?? "Unknown",
|
|
album: metadata["album"] as? String,
|
|
artist: metadata["artist"] as? String,
|
|
albumArtist: metadata["albumArtist"] as? String,
|
|
track: nil, year: nil, genre: nil,
|
|
coverArt: coverArtId,
|
|
size: nil, contentType: nil,
|
|
suffix: suffix,
|
|
transcodedContentType: nil, transcodedSuffix: nil,
|
|
duration: metadata["duration"] as? Int,
|
|
bitRate: nil, path: nil, playCount: nil,
|
|
discNumber: nil, created: nil,
|
|
albumId: nil, artistId: nil,
|
|
type: nil, starred: nil, bpm: nil, musicBrainzId: nil
|
|
)
|
|
|
|
DispatchQueue.main.async {
|
|
WatchOfflineStore.shared.addSong(song, localPath: destURL.path)
|
|
print("[Watch] Added to offline store: \(song.title)")
|
|
}
|
|
} catch {
|
|
print("[Watch] ERROR saving file: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|