From c2aea998b91d2848274e3a09211b826478e0783a Mon Sep 17 00:00:00 2001 From: Ralph von der Heyden Date: Sat, 17 Jan 2026 13:05:51 +0100 Subject: [PATCH] WIP entity refactoring --- Spotifly/SpotifyAPI/APITypes.swift | 51 ++------------ Spotifly/SpotifyAPI/SpotifyAPI+Player.swift | 2 +- Spotifly/SpotifyAPI/SpotifyAPI+Tracks.swift | 8 +-- Spotifly/SpotifyAPI/SpotifyAPI.swift | 14 ++++ Spotifly/SpotifyPlayer.swift | 40 ++++++----- Spotifly/Store/Entities.swift | 7 +- Spotifly/Store/EntityConversions.swift | 50 ------------- Spotifly/Store/Services/DeviceService.swift | 5 +- Spotifly/Store/Services/TrackService.swift | 4 +- .../ViewModels/TrackLookupViewModel.swift | 12 ++-- Spotifly/Views/AlbumDetailView.swift | 2 +- Spotifly/Views/ArtistDetailView.swift | 2 +- Spotifly/Views/Components/TrackCard.swift | 2 +- .../Views/Components/TrackContextMenu.swift | 10 +-- Spotifly/Views/FavoritesListView.swift | 2 +- Spotifly/Views/NowPlayingBarView.swift | 28 +------- Spotifly/Views/PlaylistDetailView.swift | 2 +- Spotifly/Views/QueueListView.swift | 3 +- Spotifly/Views/SearchAllTracksView.swift | 2 +- Spotifly/Views/TrackRow.swift | 70 +++++-------------- 20 files changed, 93 insertions(+), 223 deletions(-) diff --git a/Spotifly/SpotifyAPI/APITypes.swift b/Spotifly/SpotifyAPI/APITypes.swift index 2a3996f..55baa47 100644 --- a/Spotifly/SpotifyAPI/APITypes.swift +++ b/Spotifly/SpotifyAPI/APITypes.swift @@ -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 @@ -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. @@ -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 @@ -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, ) } diff --git a/Spotifly/SpotifyAPI/SpotifyAPI+Player.swift b/Spotifly/SpotifyAPI/SpotifyAPI+Player.swift index 27ecfc7..4d4ecca 100644 --- a/Spotifly/SpotifyAPI/SpotifyAPI+Player.swift +++ b/Spotifly/SpotifyAPI/SpotifyAPI+Player.swift @@ -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 diff --git a/Spotifly/SpotifyAPI/SpotifyAPI+Tracks.swift b/Spotifly/SpotifyAPI/SpotifyAPI+Tracks.swift index 0ac84c8..4253b6e 100644 --- a/Spotifly/SpotifyAPI/SpotifyAPI+Tracks.swift +++ b/Spotifly/SpotifyAPI/SpotifyAPI+Tracks.swift @@ -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 @@ -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 } diff --git a/Spotifly/SpotifyAPI/SpotifyAPI.swift b/Spotifly/SpotifyAPI/SpotifyAPI.swift index e7a2102..a1a5a9c 100644 --- a/Spotifly/SpotifyAPI/SpotifyAPI.swift +++ b/Spotifly/SpotifyAPI/SpotifyAPI.swift @@ -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" diff --git a/Spotifly/SpotifyPlayer.swift b/Spotifly/SpotifyPlayer.swift index b702da2..8cc788e 100644 --- a/Spotifly/SpotifyPlayer.swift +++ b/Spotifly/SpotifyPlayer.swift @@ -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? @@ -25,13 +26,16 @@ 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?, @@ -39,9 +43,9 @@ struct QueueItem: Sendable, Identifiable, Equatable, Encodable { ) { 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 @@ -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) } } @@ -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, @@ -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, @@ -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) @@ -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, @@ -577,10 +581,10 @@ private nonisolated func handleQueueCallback(_ jsonPtr: UnsafePointer?) { ) #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) diff --git a/Spotifly/Store/Entities.swift b/Spotifly/Store/Entities.swift index 14d666b..df4398a 100644 --- a/Spotifly/Store/Entities.swift +++ b/Spotifly/Store/Entities.swift @@ -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 @@ -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 diff --git a/Spotifly/Store/EntityConversions.swift b/Spotifly/Store/EntityConversions.swift index 47fd587..aaf51b2 100644 --- a/Spotifly/Store/EntityConversions.swift +++ b/Spotifly/Store/EntityConversions.swift @@ -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 { @@ -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 @@ -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 - } -} diff --git a/Spotifly/Store/Services/DeviceService.swift b/Spotifly/Store/Services/DeviceService.swift index acb2aab..10cc911 100644 --- a/Spotifly/Store/Services/DeviceService.swift +++ b/Spotifly/Store/Services/DeviceService.swift @@ -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 diff --git a/Spotifly/Store/Services/TrackService.swift b/Spotifly/Store/Services/TrackService.swift index 430ad98..a92bc4b 100644 --- a/Spotifly/Store/Services/TrackService.swift +++ b/Spotifly/Store/Services/TrackService.swift @@ -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 } diff --git a/Spotifly/ViewModels/TrackLookupViewModel.swift b/Spotifly/ViewModels/TrackLookupViewModel.swift index c268eee..dde9ad5 100644 --- a/Spotifly/ViewModels/TrackLookupViewModel.swift +++ b/Spotifly/ViewModels/TrackLookupViewModel.swift @@ -12,12 +12,12 @@ import SwiftUI final class TrackLookupViewModel { var spotifyURI: String = "" var isLoading = false - var trackMetadata: TrackMetadata? + var track: Track? var errorMessage: String? func clearInput() { spotifyURI = "" - trackMetadata = nil + track = nil errorMessage = nil } @@ -31,12 +31,12 @@ final class TrackLookupViewModel { if let trackId = SpotifyAPI.parseTrackURI(spotifyURI) { isLoading = true errorMessage = nil - trackMetadata = nil + track = nil Task { do { - let metadata = try await SpotifyAPI.fetchTrackMetadata(trackId: trackId, accessToken: accessToken) - self.trackMetadata = metadata + let apiTrack = try await SpotifyAPI.fetchTrack(trackId: trackId, accessToken: accessToken) + self.track = Track(from: apiTrack) self.isLoading = false } catch { self.errorMessage = error.localizedDescription @@ -46,7 +46,7 @@ final class TrackLookupViewModel { } else { // For non-track URIs (album/playlist/artist), we won't fetch metadata // but we'll allow playback - trackMetadata = nil + track = nil } } } diff --git a/Spotifly/Views/AlbumDetailView.swift b/Spotifly/Views/AlbumDetailView.swift index 0f7c12e..aa5b5e6 100644 --- a/Spotifly/Views/AlbumDetailView.swift +++ b/Spotifly/Views/AlbumDetailView.swift @@ -193,7 +193,7 @@ struct AlbumDetailView: View { VStack(alignment: .leading, spacing: 0) { ForEach(Array(tracks.enumerated()), id: \.offset) { index, track in TrackRow( - track: track.toTrackRowData(), + track: track, showTrackNumber: true, currentlyPlayingURI: playbackViewModel.currentlyPlayingURI, playbackViewModel: playbackViewModel, diff --git a/Spotifly/Views/ArtistDetailView.swift b/Spotifly/Views/ArtistDetailView.swift index 15615a8..c80e504 100644 --- a/Spotifly/Views/ArtistDetailView.swift +++ b/Spotifly/Views/ArtistDetailView.swift @@ -182,7 +182,7 @@ struct ArtistDetailView: View { VStack(alignment: .leading, spacing: 0) { ForEach(Array(displayedTracks.enumerated()), id: \.element.id) { index, track in TrackRow( - track: track.toTrackRowData(), + track: track, index: index, currentlyPlayingURI: playbackViewModel.currentlyPlayingURI, playbackViewModel: playbackViewModel, diff --git a/Spotifly/Views/Components/TrackCard.swift b/Spotifly/Views/Components/TrackCard.swift index 36c9034..07a24ba 100644 --- a/Spotifly/Views/Components/TrackCard.swift +++ b/Spotifly/Views/Components/TrackCard.swift @@ -58,7 +58,7 @@ struct TrackCard: View { .buttonStyle(.plain) .contextMenu { TrackContextMenu( - track: track.toTrackRowData(), + track: track, currentSection: currentSection, selectionId: nil, playbackViewModel: playbackViewModel, diff --git a/Spotifly/Views/Components/TrackContextMenu.swift b/Spotifly/Views/Components/TrackContextMenu.swift index 8b663fe..3b3f354 100644 --- a/Spotifly/Views/Components/TrackContextMenu.swift +++ b/Spotifly/Views/Components/TrackContextMenu.swift @@ -9,7 +9,7 @@ import SwiftUI /// Reusable context menu content for tracks struct TrackContextMenu: View { - let track: TrackRowData + let track: Track let currentSection: NavigationItem let selectionId: String? @Bindable var playbackViewModel: PlaybackViewModel @@ -26,7 +26,7 @@ struct TrackContextMenu: View { /// Favorite status from the store private var isFavorited: Bool { - store.isFavorite(track.trackId) + store.isFavorite(track.id) } var body: some View { @@ -151,7 +151,7 @@ struct TrackContextMenu: View { do { let token = await session.validAccessToken() try await trackService.toggleFavorite( - trackId: track.trackId, + trackId: track.id, accessToken: token, ) } catch { @@ -166,7 +166,7 @@ struct TrackContextMenu: View { let token = await session.validAccessToken() try await playlistService.addTracksToPlaylist( playlistId: playlistId, - trackIds: [track.trackId], + trackIds: [track.id], accessToken: token, ) onPlaylistAdded?() @@ -182,7 +182,7 @@ struct TrackContextMenu: View { extension TrackContextMenu { /// Initialize without playlist dialog support (for context menus) init( - track: TrackRowData, + track: Track, currentSection: NavigationItem = .startpage, selectionId: String? = nil, playbackViewModel: PlaybackViewModel, diff --git a/Spotifly/Views/FavoritesListView.swift b/Spotifly/Views/FavoritesListView.swift index 563ffd2..9940197 100644 --- a/Spotifly/Views/FavoritesListView.swift +++ b/Spotifly/Views/FavoritesListView.swift @@ -58,7 +58,7 @@ struct FavoritesListView: View { LazyVStack(spacing: 0) { ForEach(Array(store.favoriteTracks.enumerated()), id: \.element.id) { index, track in TrackRow( - track: track.toTrackRowData(), + track: track, index: index, currentlyPlayingURI: playbackViewModel.currentlyPlayingURI, playbackViewModel: playbackViewModel, diff --git a/Spotifly/Views/NowPlayingBarView.swift b/Spotifly/Views/NowPlayingBarView.swift index c822470..438aa95 100644 --- a/Spotifly/Views/NowPlayingBarView.swift +++ b/Spotifly/Views/NowPlayingBarView.swift @@ -41,28 +41,6 @@ struct NowPlayingBarView: View { return store.tracks[trackId] } - /// Current track data for the context menu - private var currentTrackData: TrackRowData? { - guard let track = currentTrack, - let uri = playbackViewModel.currentTrackUri - else { - return nil - } - - return TrackRowData( - id: track.id, - uri: uri, - name: track.name, - artistName: track.artistName, - albumArtURL: track.imageURL?.absoluteString, - durationMs: track.durationMs, - trackNumber: track.trackNumber, - albumId: track.albumId, - artistId: track.artistId, - externalUrl: track.externalUrl, - ) - } - // Fixed dimensions for the now playing bar (in points) private let barWidth: CGFloat = 700 private let barHeight: CGFloat = 60 @@ -374,7 +352,7 @@ struct NowPlayingBarView: View { @ViewBuilder private var trackMenu: some View { - if let track = currentTrackData { + if let track = currentTrack { Menu { TrackContextMenu( track: track, @@ -416,7 +394,7 @@ struct NowPlayingBarView: View { private func createAndAddToPlaylist(name: String) { let trimmedName = name.trimmingCharacters(in: .whitespaces) - guard !trimmedName.isEmpty, let track = currentTrackData else { return } + guard !trimmedName.isEmpty, let track = currentTrack else { return } Task { do { @@ -432,7 +410,7 @@ struct NowPlayingBarView: View { // Add the track to the new playlist try await playlistService.addTracksToPlaylist( playlistId: newPlaylist.id, - trackIds: [track.trackId], + trackIds: [track.id], accessToken: token, ) diff --git a/Spotifly/Views/PlaylistDetailView.swift b/Spotifly/Views/PlaylistDetailView.swift index 8ded1b7..d717792 100644 --- a/Spotifly/Views/PlaylistDetailView.swift +++ b/Spotifly/Views/PlaylistDetailView.swift @@ -293,7 +293,7 @@ struct PlaylistDetailView: View { @ViewBuilder private func trackRowView(track: Track, index: Int) -> some View { let row = TrackRow( - track: track.toTrackRowData(), + track: track, index: index, currentlyPlayingURI: playbackViewModel.currentlyPlayingURI, playbackViewModel: playbackViewModel, diff --git a/Spotifly/Views/QueueListView.swift b/Spotifly/Views/QueueListView.swift index b617999..027ec3b 100644 --- a/Spotifly/Views/QueueListView.swift +++ b/Spotifly/Views/QueueListView.swift @@ -112,9 +112,8 @@ struct QueueListView: View { ScrollView { LazyVStack(spacing: 0) { ForEach(Array(allTracks.enumerated()), id: \.offset) { index, track in - let trackData = track.toTrackRowData() TrackRow( - track: trackData, + track: track, index: index, currentlyPlayingURI: playbackViewModel.currentlyPlayingURI, currentIndex: currentIndex, diff --git a/Spotifly/Views/SearchAllTracksView.swift b/Spotifly/Views/SearchAllTracksView.swift index 7bf6c42..36e1f03 100644 --- a/Spotifly/Views/SearchAllTracksView.swift +++ b/Spotifly/Views/SearchAllTracksView.swift @@ -60,7 +60,7 @@ struct SearchAllTracksView: View { VStack(alignment: .leading, spacing: 0) { ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in TrackRow( - track: track.toTrackRowData(), + track: track, index: index, currentlyPlayingURI: playbackViewModel.currentlyPlayingURI, playbackViewModel: playbackViewModel, diff --git a/Spotifly/Views/TrackRow.swift b/Spotifly/Views/TrackRow.swift index cd5ff90..023ec72 100644 --- a/Spotifly/Views/TrackRow.swift +++ b/Spotifly/Views/TrackRow.swift @@ -7,29 +7,6 @@ import SwiftUI -/// Data needed to display a track row -struct TrackRowData: Identifiable { - let id: String - let uri: String - let name: String - let artistName: String - let albumArtURL: String? - let durationMs: Int - let trackNumber: Int? // Optional - only shown in album views - let albumId: String? // For navigation to album - let artistId: String? // For navigation to artist - let externalUrl: String? // Web URL for sharing - - var durationFormatted: String { - formatTrackTime(milliseconds: durationMs) - } - - /// Extracts the track ID for API calls (handles both plain IDs and URIs) - var trackId: String { - SpotifyAPI.parseTrackURI(id) ?? id - } -} - /// Double-tap behavior for TrackRow enum TrackRowDoubleTapBehavior { case playTrack // Play just this track @@ -37,7 +14,7 @@ enum TrackRowDoubleTapBehavior { /// Reusable track row view struct TrackRow: View { - let track: TrackRowData + let track: Track let showTrackNumber: Bool // Show track number instead of index let index: Int? // Optional index for queue let isCurrentTrack: Bool @@ -61,11 +38,11 @@ struct TrackRow: View { /// Favorite status from the store (single source of truth) private var isFavorited: Bool { - store.isFavorite(track.trackId) + store.isFavorite(track.id) } init( - track: TrackRowData, + track: Track, showTrackNumber: Bool = false, index: Int? = nil, currentlyPlayingURI: String?, @@ -115,8 +92,8 @@ struct TrackRow: View { .frame(width: 30, alignment: showTrackNumber ? .trailing : .center) // Album art (if available) - if let albumArtURL = track.albumArtURL, !albumArtURL.isEmpty, let url = URL(string: albumArtURL) { - AsyncImage(url: url) { phase in + if let imageURL = track.imageURL { + AsyncImage(url: imageURL) { phase in switch phase { case .empty: ProgressView() @@ -252,7 +229,7 @@ struct TrackRow: View { do { let token = await session.validAccessToken() try await trackService.toggleFavorite( - trackId: track.trackId, + trackId: track.id, accessToken: token, ) } catch { @@ -284,7 +261,7 @@ struct TrackRow: View { // Add the track to the new playlist try await playlistService.addTracksToPlaylist( playlistId: newPlaylist.id, - trackIds: [track.trackId], + trackIds: [track.id], accessToken: token, ) @@ -305,38 +282,23 @@ struct TrackRow: View { } } -// MARK: - Conversions from different track types +// MARK: - QueueItem to Track Conversion extension QueueItem { - func toTrackRowData() -> TrackRowData { - TrackRowData( - id: uri, + /// Convert QueueItem to Track for use with TrackRow and store operations + func toTrack() -> Track { + Track( + id: SpotifyAPI.parseTrackURI(uri) ?? id, + name: name, uri: uri, - name: trackName, - artistName: artistName, - albumArtURL: albumArtURL, durationMs: Int(durationMs), trackNumber: nil, - albumId: albumId, - artistId: artistId, externalUrl: externalUrl, - ) - } -} - -extension APITrack { - 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, + artistName: artistName, + albumName: nil, + imageURL: imageURL, ) } }