Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 5 additions & 46 deletions Spotifly/SpotifyAPI/APITypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,20 +187,9 @@ struct RecentlyPlayedResponse: Sendable {

// MARK: - Playback & Connect Types

/// Spotify Connect device
struct SpotifyDevice: Sendable, Identifiable {
let id: String
let isActive: Bool
let isPrivateSession: Bool
let isRestricted: Bool
let name: String
let type: String // "Computer", "Smartphone", "Speaker", etc.
let volumePercent: Int?
}

/// Devices response wrapper
struct DevicesResponse: Sendable {
let devices: [SpotifyDevice]
let devices: [Device]
}

// MARK: - User Top Items
Expand All @@ -212,23 +201,6 @@ enum TopItemsTimeRange: String, Sendable {
case shortTerm = "short_term" // ~4 weeks
}

// MARK: - Legacy Track Types (to be removed after migration)

/// Track metadata from single track lookup
struct TrackMetadata: Sendable {
let id: String
let albumImageURL: URL?
let albumName: String
let artistName: String
let durationMs: Int
let name: String
let previewURL: URL?

var durationFormatted: String {
formatTrackTime(milliseconds: durationMs)
}
}

// MARK: - Codable Response Types (Internal)

/// These types are used only for JSON decoding from Spotify API responses.
Expand Down Expand Up @@ -406,19 +378,6 @@ struct TrackCodable: Decodable {
uri: uri,
)
}

func toTrackMetadata() -> TrackMetadata {
let artistNames = artists?.compactMap(\.name).joined(separator: ", ") ?? "Unknown Artist"
return TrackMetadata(
id: id,
albumImageURL: (album?.images?.first?.url).flatMap { URL(string: $0) },
albumName: album?.name ?? "Unknown Album",
artistName: artistNames,
durationMs: durationMs,
name: name,
previewURL: previewUrl.flatMap { URL(string: $0) },
)
}
}

// MARK: Playlist Codable
Expand Down Expand Up @@ -493,15 +452,15 @@ struct DeviceCodable: Decodable {
case volumePercent = "volume_percent"
}

func toSpotifyDevice() -> SpotifyDevice? {
func toDevice() -> Device? {
guard let id else { return nil }
return SpotifyDevice(
return Device(
id: id,
name: name,
type: type,
isActive: isActive ?? false,
isPrivateSession: isPrivateSession ?? false,
isRestricted: isRestricted ?? false,
name: name,
type: type,
volumePercent: volumePercent,
)
}
Expand Down
2 changes: 1 addition & 1 deletion Spotifly/SpotifyAPI/SpotifyAPI+Player.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ extension SpotifyAPI {
case 200:
do {
let decoded = try JSONDecoder().decode(DevicesCodable.self, from: data)
let devices = decoded.devices.compactMap { $0.toSpotifyDevice() }
let devices = decoded.devices.compactMap { $0.toDevice() }
return DevicesResponse(devices: devices)
} catch {
throw SpotifyAPIError.invalidResponse
Expand Down
8 changes: 4 additions & 4 deletions Spotifly/SpotifyAPI/SpotifyAPI+Tracks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import os
extension SpotifyAPI {
// MARK: - Single Track

/// Fetches track metadata from Spotify Web API
static func fetchTrackMetadata(trackId: String, accessToken: String) async throws -> TrackMetadata {
let urlString = "\(baseURL)/tracks/\(trackId)?fields=id,name,duration_ms,artists(name),album(name,images),preview_url"
/// Fetches a single track from Spotify Web API
static func fetchTrack(trackId: String, accessToken: String) async throws -> APITrack {
let urlString = "\(baseURL)/tracks/\(trackId)"
#if DEBUG
apiLogger.debug("[GET] \(urlString)")
#endif
Expand All @@ -35,7 +35,7 @@ extension SpotifyAPI {
case 200:
do {
let track = try JSONDecoder().decode(TrackCodable.self, from: data)
return track.toTrackMetadata()
return track.toAPITrack()
} catch {
throw SpotifyAPIError.invalidResponse
}
Expand Down
14 changes: 14 additions & 0 deletions Spotifly/SpotifyAPI/SpotifyAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ import os.log

let apiLogger = Logger(subsystem: "com.spotifly.app", category: "SpotifyAPI")

/// Spotify item types for generating external URLs
enum SpotifyItemType: String {
case track
case album
case artist
case playlist
case user
}

/// Generates a Spotify external URL from item type and ID
func spotifyExternalUrl(type: SpotifyItemType, id: String) -> String {
"https://open.spotify.com/\(type.rawValue)/\(id)"
}

/// Spotify Web API client
enum SpotifyAPI {
static let baseURL = "https://api.spotify.com/v1"
Expand Down
40 changes: 22 additions & 18 deletions Spotifly/SpotifyPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import Foundation
import SpotiflyRust

/// Queue item metadata (nonisolated for C callback compatibility)
/// Field names aligned with Track for consistency
struct QueueItem: Sendable, Identifiable, Equatable, Encodable {
nonisolated let id: String // uri
nonisolated let uri: String
nonisolated let trackName: String
nonisolated let name: String // Aligned with Track.name (was: trackName)
nonisolated let artistName: String
nonisolated let albumArtURL: String
nonisolated let imageURLString: String // Aligned with Track (was: albumArtURL)
nonisolated let durationMs: UInt32
nonisolated let albumId: String?
nonisolated let artistId: String?
Expand All @@ -25,23 +26,26 @@ struct QueueItem: Sendable, Identifiable, Equatable, Encodable {
formatTrackTime(milliseconds: Int(durationMs))
}

/// Computed property for URL conversion
var imageURL: URL? { URL(string: imageURLString) }

/// Memberwise initializer
nonisolated init(
id: String,
uri: String,
trackName: String,
name: String,
artistName: String,
albumArtURL: String,
imageURLString: String,
durationMs: UInt32,
albumId: String?,
artistId: String?,
externalUrl: String?,
) {
self.id = id
self.uri = uri
self.trackName = trackName
self.name = name
self.artistName = artistName
self.albumArtURL = albumArtURL
self.imageURLString = imageURLString
self.durationMs = durationMs
self.albumId = albumId
self.artistId = artistId
Expand All @@ -52,13 +56,13 @@ struct QueueItem: Sendable, Identifiable, Equatable, Encodable {
@MainActor init(from track: APITrack) {
id = track.uri
uri = track.uri
trackName = track.name
name = track.name
artistName = track.artistName
albumArtURL = track.imageURL?.absoluteString ?? ""
imageURLString = track.imageURL?.absoluteString ?? ""
durationMs = UInt32(track.durationMs)
albumId = track.albumId
artistId = track.artistId
externalUrl = track.externalUrl ?? "https://open.spotify.com/track/\(track.id)"
externalUrl = track.externalUrl ?? spotifyExternalUrl(type: .track, id: track.id)
}
}

Expand Down Expand Up @@ -369,9 +373,9 @@ private func fetchAndEmitQueueState(retryOnEmpty: Bool = true) async {
QueueItem(
id: track.uri,
uri: track.uri,
trackName: track.name,
name: track.name,
artistName: track.artists?.first?.name ?? "Unknown Artist",
albumArtURL: track.album?.images?.first?.url ?? "",
imageURLString: track.album?.images?.first?.url ?? "",
durationMs: UInt32(track.durationMs),
albumId: track.album?.id,
artistId: track.artists?.first?.id,
Expand All @@ -383,9 +387,9 @@ private func fetchAndEmitQueueState(retryOnEmpty: Bool = true) async {
QueueItem(
id: track.uri,
uri: track.uri,
trackName: track.name,
name: track.name,
artistName: track.artists?.first?.name ?? "Unknown Artist",
albumArtURL: track.album?.images?.first?.url ?? "",
imageURLString: track.album?.images?.first?.url ?? "",
durationMs: UInt32(track.durationMs),
albumId: track.album?.id,
artistId: track.artists?.first?.id,
Expand All @@ -402,7 +406,7 @@ private func fetchAndEmitQueueState(retryOnEmpty: Bool = true) async {
queueSubject.send(queueState)

#if DEBUG
print("[SpotifyPlayer] Queue fetched from Web API: current=\(currentTrack?.trackName ?? "none"), next=\(nextTracks.count) tracks")
print("[SpotifyPlayer] Queue fetched from Web API: current=\(currentTrack?.name ?? "none"), next=\(nextTracks.count) tracks")
#endif

// If we have a current track but empty queue, retry after delay (device activation settling)
Expand Down Expand Up @@ -504,9 +508,9 @@ private nonisolated func parseQueueItem(from dict: [String: Any]) -> QueueItem?
return QueueItem(
id: uri,
uri: uri,
trackName: dict["name"] as? String ?? "",
name: dict["name"] as? String ?? "",
artistName: dict["artist"] as? String ?? "",
albumArtURL: dict["image_url"] as? String ?? "",
imageURLString: dict["image_url"] as? String ?? "",
durationMs: (dict["duration_ms"] as? NSNumber)?.uint32Value ?? 0,
albumId: nil,
artistId: nil,
Expand Down Expand Up @@ -577,10 +581,10 @@ private nonisolated func handleQueueCallback(_ jsonPtr: UnsafePointer<CChar>?) {
)

#if DEBUG
let trackName = state.currentTrack?.trackName ?? "none"
let currentName = state.currentTrack?.name ?? "none"
let nextCount = state.nextTracks.count
let prevCount = state.previousTracks?.count ?? 0
print("[SpotifyPlayer] handleQueueCallback: current='\(trackName)', next=\(nextCount), prev=\(prevCount)")
print("[SpotifyPlayer] handleQueueCallback: current='\(currentName)', next=\(nextCount), prev=\(prevCount)")
#endif

queueSubject.send(state)
Expand Down
7 changes: 6 additions & 1 deletion Spotifly/Store/Entities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import Foundation
// MARK: - Track

/// Unified track entity - single source of truth for all track data.
/// Constructed from APITrack or TrackMetadata via EntityConversions.
/// Constructed from APITrack via EntityConversions.
struct Track: Identifiable, Sendable, Hashable, Encodable {
let id: String
let name: String
Expand All @@ -32,6 +32,11 @@ struct Track: Identifiable, Sendable, Hashable, Encodable {
var durationFormatted: String {
formatTrackTime(milliseconds: durationMs)
}

/// Returns externalUrl if available, otherwise generates from ID
var externalUrlOrGenerated: String {
externalUrl ?? spotifyExternalUrl(type: .track, id: id)
}
}

// MARK: - Album
Expand Down
50 changes: 0 additions & 50 deletions Spotifly/Store/EntityConversions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,6 @@

import Foundation

// MARK: - Track to TrackRowData Conversion

extension Track {
/// Convert to TrackRowData for use with TrackRow view
func toTrackRowData() -> TrackRowData {
TrackRowData(
id: id,
uri: uri,
name: name,
artistName: artistName,
albumArtURL: imageURL?.absoluteString,
durationMs: durationMs,
trackNumber: trackNumber,
albumId: albumId,
artistId: artistId,
externalUrl: externalUrl,
)
}
}

// MARK: - Track Conversions

extension Track {
Expand Down Expand Up @@ -60,21 +40,6 @@ extension Track {
self.albumName = albumName
self.imageURL = imageURL
}

/// Convert from TrackMetadata (single track lookup)
init(from metadata: TrackMetadata) {
id = metadata.id
name = metadata.name
uri = "spotify:track:\(metadata.id)"
durationMs = metadata.durationMs
trackNumber = nil
externalUrl = nil
albumId = nil
artistId = nil
artistName = metadata.artistName
albumName = metadata.albumName
imageURL = metadata.albumImageURL
}
}

// MARK: - Album Conversions
Expand Down Expand Up @@ -171,18 +136,3 @@ extension Playlist {
)
}
}

// MARK: - Device Conversions

extension Device {
/// Convert from SpotifyDevice
init(from device: SpotifyDevice) {
id = device.id
name = device.name
type = device.type
isActive = device.isActive
isPrivateSession = device.isPrivateSession
isRestricted = device.isRestricted
volumePercent = device.volumePercent
}
}
5 changes: 2 additions & 3 deletions Spotifly/Store/Services/DeviceService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,10 @@ final class DeviceService {

do {
let response = try await SpotifyAPI.fetchAvailableDevices(accessToken: accessToken)
let devices = response.devices.map { Device(from: $0) }
store.upsertDevices(devices)
store.upsertDevices(response.devices)

// Track active device ID
if let activeDevice = devices.first(where: { $0.isActive }) {
if let activeDevice = response.devices.first(where: { $0.isActive }) {
store.activeDeviceId = activeDevice.id
} else {
store.activeDeviceId = nil
Expand Down
4 changes: 2 additions & 2 deletions Spotifly/Store/Services/TrackService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,12 @@ final class TrackService {

/// Fetch and store a single track by ID
func fetchTrack(trackId: String, accessToken: String) async throws -> Track {
let metadata = try await SpotifyAPI.fetchTrackMetadata(
let apiTrack = try await SpotifyAPI.fetchTrack(
trackId: trackId,
accessToken: accessToken,
)

let track = Track(from: metadata)
let track = Track(from: apiTrack)
store.upsertTrack(track)
return track
}
Expand Down
Loading