Skip to content

Commit a7b5874

Browse files
committed
Add ability to manually reorder tracks within playlists
Adds ability to manually reorder tracks within playlists by setting sort order to `Custom`. Fixes #197
1 parent fca62e4 commit a7b5874

8 files changed

Lines changed: 533 additions & 135 deletions

File tree

Managers/Playlist/PMRegularPlaylists.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,28 @@ extension PlaylistManager {
270270
}
271271
}
272272

273+
/// Reorder tracks within a playlist
274+
func reorderPlaylistTracks(playlistID: UUID, reorderedTracks: [Track]) async {
275+
guard let index = playlists.firstIndex(where: { $0.id == playlistID }),
276+
playlists[index].type == .regular,
277+
playlists[index].isContentEditable else { return }
278+
279+
await MainActor.run {
280+
self.playlists[index].tracks = reorderedTracks
281+
self.playlists[index].dateModified = Date()
282+
}
283+
284+
let updatedPlaylist = await MainActor.run { self.playlists[index] }
285+
do {
286+
if let dbManager = libraryManager?.databaseManager {
287+
try await dbManager.savePlaylistAsync(updatedPlaylist)
288+
Logger.info("Saved reordered playlist '\(updatedPlaylist.name)' with \(updatedPlaylist.tracks.count) tracks")
289+
}
290+
} catch {
291+
Logger.error("Failed to save reordered playlist: \(error)")
292+
}
293+
}
294+
273295
/// Refresh playlists after a folder is removed from the library
274296
func refreshPlaylistsAfterFolderRemoval() {
275297
// Remove tracks that no longer exist from regular playlists

Managers/PlaylistSortManager.swift

Lines changed: 56 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ import SwiftUI
99
/// Manages playlist-specific sorting preferences
1010
class PlaylistSortManager: ObservableObject {
1111
static let shared = PlaylistSortManager()
12-
12+
13+
// Legacy type kept for API compatibility with existing callers
1314
enum SortCriteria: String, CaseIterable {
1415
case dateAdded
1516
case title
1617
case custom
17-
18+
1819
var displayName: String {
1920
switch self {
2021
case .dateAdded: return "Date added"
@@ -23,78 +24,81 @@ class PlaylistSortManager: ObservableObject {
2324
}
2425
}
2526
}
26-
27+
2728
// Store sort preferences per playlist
28-
@AppStorage("playlistSortCriteria")
29-
private var sortCriteriaData: Data = Data()
30-
29+
@AppStorage("playlistSortFields")
30+
private var sortFieldsData: Data = Data()
31+
3132
@AppStorage("playlistSortAscending")
3233
private var sortAscendingData: Data = Data()
33-
34-
// Cache for current table sort column (when custom is selected)
35-
private var customSortColumns: [UUID: String] = [:]
36-
37-
private var sortCriteria: [UUID: SortCriteria] = [:] {
38-
didSet {
39-
savePreferences()
40-
}
34+
35+
private var sortFields: [UUID: String] = [:] {
36+
didSet { savePreferences() }
4137
}
42-
38+
4339
private var sortAscending: [UUID: Bool] = [:] {
44-
didSet {
45-
savePreferences()
46-
}
40+
didSet { savePreferences() }
4741
}
48-
42+
4943
init() {
5044
loadPreferences()
5145
}
52-
46+
5347
// MARK: - Public Methods
54-
48+
49+
func getSortField(for playlistID: UUID) -> TrackSortField {
50+
guard let rawValue = sortFields[playlistID],
51+
let field = TrackSortField(rawValue: rawValue) else {
52+
return .dateAdded
53+
}
54+
return field
55+
}
56+
5557
func getSortCriteria(for playlistID: UUID) -> SortCriteria {
56-
sortCriteria[playlistID] ?? .dateAdded
58+
let field = getSortField(for: playlistID)
59+
switch field {
60+
case .custom: return .custom
61+
case .title: return .title
62+
case .dateAdded: return .dateAdded
63+
default: return .custom
64+
}
5765
}
58-
66+
5967
func getSortAscending(for playlistID: UUID) -> Bool {
6068
sortAscending[playlistID] ?? true
6169
}
62-
63-
func setSortCriteria(_ criteria: SortCriteria, for playlistID: UUID) {
64-
sortCriteria[playlistID] = criteria
70+
71+
func setSortField(_ field: TrackSortField, for playlistID: UUID) {
72+
sortFields[playlistID] = field.rawValue
6573
objectWillChange.send()
6674
}
67-
75+
76+
func setSortCriteria(_ criteria: SortCriteria, for playlistID: UUID) {
77+
let field: TrackSortField
78+
switch criteria {
79+
case .custom: field = .custom
80+
case .title: field = .title
81+
case .dateAdded: field = .dateAdded
82+
}
83+
setSortField(field, for: playlistID)
84+
}
85+
6886
func setSortAscending(_ ascending: Bool, for playlistID: UUID) {
6987
sortAscending[playlistID] = ascending
7088
objectWillChange.send()
7189
}
72-
73-
func setCustomSortColumn(_ column: String, for playlistID: UUID) {
74-
customSortColumns[playlistID] = column
75-
setSortCriteria(.custom, for: playlistID)
76-
}
77-
78-
func getCustomSortColumn(for playlistID: UUID) -> String? {
79-
customSortColumns[playlistID]
80-
}
81-
90+
8291
// MARK: - Persistence
83-
92+
8493
private func loadPreferences() {
85-
// Load sort criteria
86-
if let decoded = try? JSONDecoder().decode([String: String].self, from: sortCriteriaData) {
87-
sortCriteria = decoded.compactMapValues { rawValue in
88-
SortCriteria(rawValue: rawValue)
89-
}
90-
.reduce(into: [:]) { result, pair in
94+
if let decoded = try? JSONDecoder().decode([String: String].self, from: sortFieldsData) {
95+
sortFields = decoded.reduce(into: [:]) { result, pair in
9196
if let uuid = UUID(uuidString: pair.key) {
9297
result[uuid] = pair.value
9398
}
9499
}
95100
}
96-
97-
// Load sort ascending
101+
98102
if let decoded = try? JSONDecoder().decode([String: Bool].self, from: sortAscendingData) {
99103
sortAscending = decoded.reduce(into: [:]) { result, pair in
100104
if let uuid = UUID(uuidString: pair.key) {
@@ -103,17 +107,15 @@ class PlaylistSortManager: ObservableObject {
103107
}
104108
}
105109
}
106-
110+
107111
private func savePreferences() {
108-
// Save sort criteria
109-
let criteriaDict = sortCriteria.reduce(into: [String: String]()) { result, pair in
110-
result[pair.key.uuidString] = pair.value.rawValue
112+
let fieldsDict = sortFields.reduce(into: [String: String]()) { result, pair in
113+
result[pair.key.uuidString] = pair.value
111114
}
112-
if let encoded = try? JSONEncoder().encode(criteriaDict) {
113-
sortCriteriaData = encoded
115+
if let encoded = try? JSONEncoder().encode(fieldsDict) {
116+
sortFieldsData = encoded
114117
}
115-
116-
// Save sort ascending
118+
117119
let ascendingDict = sortAscending.reduce(into: [String: Bool]()) { result, pair in
118120
result[pair.key.uuidString] = pair.value
119121
}

Models/Core/Playlist.swift

Lines changed: 48 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -314,77 +314,80 @@ struct Playlist: Identifiable, FetchableRecord, PersistableRecord {
314314
// Check if all tracks are from the same album
315315
let uniqueAlbums = Set(tracks.map { $0.album })
316316
let isSingleAlbum = uniqueAlbums.count == 1
317-
317+
318318
// If single album or single track, just use the first track's artwork
319319
if isSingleAlbum || tracks.count == 1 {
320320
return tracks.first?.artworkData
321321
}
322-
322+
323323
// Get up to 4 tracks with artwork for collage
324324
let tracksWithArt = tracks.prefix(4).filter { $0.artworkData != nil }
325-
325+
326326
guard !tracksWithArt.isEmpty else { return nil }
327-
328-
let imageSize: CGFloat = 256
329-
let collageImage = NSImage(size: NSSize(width: imageSize, height: imageSize))
330-
331-
collageImage.lockFocus()
332-
333-
// Clear background
334-
NSColor.black.setFill()
335-
NSRect(x: 0, y: 0, width: imageSize, height: imageSize).fill()
336-
327+
328+
let pixelSize = 256
329+
let colorSpace = CGColorSpaceCreateDeviceRGB()
330+
331+
// Create opaque bitmap context (no alpha) to avoid HEIC encoder warnings
332+
guard let context = CGContext(
333+
data: nil,
334+
width: pixelSize,
335+
height: pixelSize,
336+
bitsPerComponent: 8,
337+
bytesPerRow: 0,
338+
space: colorSpace,
339+
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
340+
) else {
341+
Logger.warning("Failed to create CGContext for collage")
342+
return nil
343+
}
344+
345+
// Fill black background
346+
context.setFillColor(CGColor(red: 0, green: 0, blue: 0, alpha: 1))
347+
context.fill(CGRect(x: 0, y: 0, width: pixelSize, height: pixelSize))
348+
337349
let count = tracksWithArt.count
338-
339-
// Special handling based on track count
350+
let size = CGFloat(pixelSize)
351+
340352
if count == 1 {
341-
// Single track - just draw it full size (this case is already handled above, but keeping for safety)
342353
if let artworkData = tracksWithArt[0].artworkData,
343-
let image = NSImage(data: artworkData) {
344-
image.draw(in: NSRect(x: 0, y: 0, width: imageSize, height: imageSize),
345-
from: NSRect(origin: .zero, size: image.size),
346-
operation: .copy,
347-
fraction: 1.0)
354+
let source = CGImageSourceCreateWithData(artworkData as CFData, nil),
355+
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) {
356+
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: size, height: size))
348357
}
349358
} else {
350359
// 2 or more tracks - always create 2x2 grid
351-
let positions = [(0, 0), (1, 0), (0, 1), (1, 1)] // (col, row) for each quadrant
352-
360+
let positions = [(0, 0), (1, 0), (0, 1), (1, 1)]
361+
353362
for (index, (col, row)) in positions.enumerated() {
354363
let trackIndex: Int
355-
364+
356365
if count == 2 {
357-
// For 2 tracks: diagonal pattern (0, 1, 1, 0)
358366
trackIndex = (index == 0 || index == 3) ? 0 : 1
359367
} else {
360-
// For 3+ tracks: use available tracks, repeating if necessary
361368
trackIndex = index % count
362369
}
363-
370+
364371
guard let artworkData = tracksWithArt[trackIndex].artworkData,
365-
let image = NSImage(data: artworkData) else { continue }
366-
367-
let destRect = NSRect(
368-
x: CGFloat(col) * imageSize / 2,
369-
y: CGFloat(row) * imageSize / 2,
370-
width: imageSize / 2,
371-
height: imageSize / 2
372+
let source = CGImageSourceCreateWithData(artworkData as CFData, nil),
373+
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else { continue }
374+
375+
let destRect = CGRect(
376+
x: CGFloat(col) * size / 2,
377+
y: CGFloat(row) * size / 2,
378+
width: size / 2,
379+
height: size / 2
372380
)
373-
374-
image.draw(in: destRect,
375-
from: NSRect(origin: .zero, size: image.size),
376-
operation: .copy,
377-
fraction: 1.0)
381+
382+
context.draw(cgImage, in: destRect)
378383
}
379384
}
380-
381-
collageImage.unlockFocus()
382-
383-
guard let cgImage = collageImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
384-
Logger.warning("Failed to create CGImage from collage")
385+
386+
guard let collageImage = context.makeImage() else {
387+
Logger.warning("Failed to create CGImage from collage context")
385388
return nil
386389
}
387-
return ImageUtils.encodeHEIC(cgImage)
390+
return ImageUtils.encodeHEIC(collageImage)
388391
}
389392
}
390393

Utilities/Constants.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ enum Icons {
7777
// Sort Icons
7878
static let sortAscending = "sort.ascending"
7979
static let sortDescending = "sort.descending"
80+
static let reorderTracks = "arrow.up.and.down.text.horizontal"
8081

8182
// Custom Icons (from project assets)
8283
static let customLossless = "custom.lossless"

0 commit comments

Comments
 (0)