Compare commits
No commits in common. 'd20bb2fef45479e048776a9870bf9d8f8e775d00' and '87410196afcf09110deeb11a368845b03e61bdce' have entirely different histories.
d20bb2fef4
...
87410196af
@ -1,128 +0,0 @@ |
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<Scheme |
||||
LastUpgradeVersion = "1600" |
||||
version = "1.7"> |
||||
<BuildAction |
||||
parallelizeBuildables = "YES" |
||||
buildImplicitDependencies = "YES"> |
||||
<BuildActionEntries> |
||||
<BuildActionEntry |
||||
buildForTesting = "YES" |
||||
buildForRunning = "YES" |
||||
buildForProfiling = "YES" |
||||
buildForArchiving = "YES" |
||||
buildForAnalyzing = "YES"> |
||||
<BuildableReference |
||||
BuildableIdentifier = "primary" |
||||
BlueprintIdentifier = "C46B2C8C2FC2448700F95A24" |
||||
BuildableName = "Mumu.app" |
||||
BlueprintName = "Music" |
||||
ReferencedContainer = "container:Music.xcodeproj"> |
||||
</BuildableReference> |
||||
</BuildActionEntry> |
||||
<BuildActionEntry |
||||
buildForTesting = "YES" |
||||
buildForRunning = "NO" |
||||
buildForProfiling = "NO" |
||||
buildForArchiving = "NO" |
||||
buildForAnalyzing = "NO"> |
||||
<BuildableReference |
||||
BuildableIdentifier = "primary" |
||||
BlueprintIdentifier = "C46B2C992FC2448800F95A24" |
||||
BuildableName = "MusicTests.xctest" |
||||
BlueprintName = "MusicTests" |
||||
ReferencedContainer = "container:Music.xcodeproj"> |
||||
</BuildableReference> |
||||
</BuildActionEntry> |
||||
<BuildActionEntry |
||||
buildForTesting = "YES" |
||||
buildForRunning = "NO" |
||||
buildForProfiling = "NO" |
||||
buildForArchiving = "NO" |
||||
buildForAnalyzing = "NO"> |
||||
<BuildableReference |
||||
BuildableIdentifier = "primary" |
||||
BlueprintIdentifier = "C46B2CA32FC2448800F95A24" |
||||
BuildableName = "MusicUITests.xctest" |
||||
BlueprintName = "MusicUITests" |
||||
ReferencedContainer = "container:Music.xcodeproj"> |
||||
</BuildableReference> |
||||
</BuildActionEntry> |
||||
</BuildActionEntries> |
||||
</BuildAction> |
||||
<TestAction |
||||
buildConfiguration = "Debug" |
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" |
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" |
||||
shouldUseLaunchSchemeArgsEnv = "YES"> |
||||
<Testables> |
||||
<TestableReference |
||||
skipped = "NO" |
||||
parallelizable = "NO"> |
||||
<BuildableReference |
||||
BuildableIdentifier = "primary" |
||||
BlueprintIdentifier = "C46B2C992FC2448800F95A24" |
||||
BuildableName = "MusicTests.xctest" |
||||
BlueprintName = "MusicTests" |
||||
ReferencedContainer = "container:Music.xcodeproj"> |
||||
</BuildableReference> |
||||
</TestableReference> |
||||
<TestableReference |
||||
skipped = "NO" |
||||
parallelizable = "NO"> |
||||
<BuildableReference |
||||
BuildableIdentifier = "primary" |
||||
BlueprintIdentifier = "C46B2CA32FC2448800F95A24" |
||||
BuildableName = "MusicUITests.xctest" |
||||
BlueprintName = "MusicUITests" |
||||
ReferencedContainer = "container:Music.xcodeproj"> |
||||
</BuildableReference> |
||||
</TestableReference> |
||||
</Testables> |
||||
</TestAction> |
||||
<LaunchAction |
||||
buildConfiguration = "Debug" |
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" |
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" |
||||
launchStyle = "0" |
||||
useCustomWorkingDirectory = "NO" |
||||
ignoresPersistentStateOnLaunch = "NO" |
||||
debugDocumentVersioning = "YES" |
||||
debugServiceExtension = "internal" |
||||
allowLocationSimulation = "YES"> |
||||
<BuildableProductRunnable |
||||
runnableDebuggingMode = "0"> |
||||
<BuildableReference |
||||
BuildableIdentifier = "primary" |
||||
BlueprintIdentifier = "C46B2C8C2FC2448700F95A24" |
||||
BuildableName = "Mumu.app" |
||||
BlueprintName = "Music" |
||||
ReferencedContainer = "container:Music.xcodeproj"> |
||||
</BuildableReference> |
||||
</BuildableProductRunnable> |
||||
</LaunchAction> |
||||
<ProfileAction |
||||
buildConfiguration = "Release" |
||||
shouldUseLaunchSchemeArgsEnv = "YES" |
||||
savedToolIdentifier = "" |
||||
useCustomWorkingDirectory = "NO" |
||||
debugDocumentVersioning = "YES"> |
||||
<BuildableProductRunnable |
||||
runnableDebuggingMode = "0"> |
||||
<BuildableReference |
||||
BuildableIdentifier = "primary" |
||||
BlueprintIdentifier = "C46B2C8C2FC2448700F95A24" |
||||
BuildableName = "Mumu.app" |
||||
BlueprintName = "Music" |
||||
ReferencedContainer = "container:Music.xcodeproj"> |
||||
</BuildableReference> |
||||
</BuildableProductRunnable> |
||||
</ProfileAction> |
||||
<AnalyzeAction |
||||
buildConfiguration = "Debug"> |
||||
</AnalyzeAction> |
||||
<ArchiveAction |
||||
buildConfiguration = "Release" |
||||
revealArchiveInOrganizer = "YES"> |
||||
</ArchiveAction> |
||||
</Scheme> |
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 5.8 KiB |
@ -1,34 +0,0 @@ |
||||
import Foundation |
||||
import GRDB |
||||
|
||||
nonisolated struct SmartPlaylist: Codable, Identifiable, Equatable, Hashable, Sendable { |
||||
var id: Int64? |
||||
var name: String |
||||
var searchQuery: String |
||||
var createdAt: Date |
||||
} |
||||
|
||||
nonisolated extension SmartPlaylist: FetchableRecord, MutablePersistableRecord { |
||||
static let databaseTableName = "smart_playlists" |
||||
|
||||
mutating func didInsert(_ inserted: InsertionSuccess) { |
||||
id = inserted.rowID |
||||
} |
||||
} |
||||
|
||||
extension SmartPlaylist: PlaylistRepresentable { |
||||
var isSmartPlaylist: Bool { true } |
||||
} |
||||
|
||||
#if DEBUG |
||||
extension SmartPlaylist { |
||||
static func fixture( |
||||
id: Int64? = nil, |
||||
name: String = "Test Smart Playlist", |
||||
searchQuery: String = "test query", |
||||
createdAt: Date = Date() |
||||
) -> SmartPlaylist { |
||||
SmartPlaylist(id: id, name: name, searchQuery: searchQuery, createdAt: createdAt) |
||||
} |
||||
} |
||||
#endif |
||||
@ -1,7 +0,0 @@ |
||||
import Foundation |
||||
|
||||
protocol PlaylistRepresentable: Identifiable, Hashable, Sendable { |
||||
var id: Int64? { get } |
||||
var name: String { get } |
||||
var isSmartPlaylist: Bool { get } |
||||
} |
||||
@ -1,139 +0,0 @@ |
||||
import SwiftUI |
||||
import Charts |
||||
|
||||
struct HomeView: View { |
||||
let recentTracks: [Track] |
||||
let trackCount: Int |
||||
let totalDuration: Double |
||||
let monthlyAdditions: [MonthlyCount] |
||||
let onTrackDoubleClick: (Track) -> Void |
||||
let onShowAll: () -> Void |
||||
|
||||
@State private var selectedTrack: Track? |
||||
|
||||
var body: some View { |
||||
HStack(alignment: .top, spacing: 0) { |
||||
recentlyAddedPanel |
||||
.frame(maxWidth: .infinity, maxHeight: .infinity) |
||||
|
||||
Divider() |
||||
|
||||
statsPanel |
||||
.frame(minWidth: 300, maxWidth: 300, maxHeight: .infinity) |
||||
} |
||||
.background(.white) |
||||
} |
||||
|
||||
private var recentlyAddedPanel: some View { |
||||
VStack(alignment: .leading, spacing: 0) { |
||||
HStack { |
||||
Text("Recently Added") |
||||
.font(.title2.weight(.semibold)) |
||||
Spacer() |
||||
Button("Show All", action: onShowAll) |
||||
.buttonStyle(.plain) |
||||
.foregroundStyle(.secondary) |
||||
} |
||||
.padding(.horizontal, 16) |
||||
.padding(.top, 12) |
||||
.padding(.bottom, 8) |
||||
|
||||
ScrollView { |
||||
LazyVStack(alignment: .leading, spacing: 0) { |
||||
ForEach(recentTracks) { track in |
||||
VStack(alignment: .leading, spacing: 2) { |
||||
Text(track.title) |
||||
.font(.system(size: 13, weight: .medium)) |
||||
.lineLimit(1) |
||||
Text(track.artist) |
||||
.font(.system(size: 12)) |
||||
.foregroundStyle(.secondary) |
||||
.lineLimit(1) |
||||
} |
||||
.frame(maxWidth: .infinity, alignment: .leading) |
||||
.padding(.vertical, 4) |
||||
.padding(.horizontal, 16) |
||||
.background( |
||||
selectedTrack == track |
||||
? Color.accentColor.opacity(0.2) |
||||
: Color.clear |
||||
) |
||||
.clipShape(RoundedRectangle(cornerRadius: 4)) |
||||
.contentShape(Rectangle()) |
||||
.onTapGesture(count: 2) { |
||||
onTrackDoubleClick(track) |
||||
} |
||||
.simultaneousGesture(TapGesture().onEnded { |
||||
selectedTrack = track |
||||
}) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private var statsPanel: some View { |
||||
VStack(alignment: .leading, spacing: 24) { |
||||
VStack(alignment: .leading, spacing: 12) { |
||||
Text("Library") |
||||
.font(.title2.weight(.semibold)) |
||||
|
||||
VStack(alignment: .leading, spacing: 8) { |
||||
Label( |
||||
"\(trackCount.formatted()) tracks", |
||||
systemImage: "music.note" |
||||
) |
||||
.font(.system(size: 13)) |
||||
|
||||
Label( |
||||
Self.formatTotalDuration(totalDuration), |
||||
systemImage: "clock" |
||||
) |
||||
.font(.system(size: 13)) |
||||
} |
||||
} |
||||
|
||||
if !monthlyAdditions.isEmpty { |
||||
VStack(alignment: .leading, spacing: 8) { |
||||
Text("Added per Month") |
||||
.font(.system(size: 12, weight: .medium)) |
||||
.foregroundStyle(.secondary) |
||||
|
||||
Chart(monthlyAdditions, id: \.month) { item in |
||||
BarMark( |
||||
x: .value("Month", item.month, unit: .month), |
||||
y: .value("Tracks", item.count) |
||||
) |
||||
.foregroundStyle(Color.accentColor) |
||||
} |
||||
.chartXAxis { |
||||
AxisMarks(values: .stride(by: .month, count: 2)) { value in |
||||
AxisValueLabel(format: .dateTime.month(.abbreviated)) |
||||
} |
||||
} |
||||
.frame(height: 150) |
||||
} |
||||
} |
||||
|
||||
Spacer() |
||||
} |
||||
.padding(16) |
||||
} |
||||
|
||||
private static func formatTotalDuration(_ seconds: Double) -> String { |
||||
guard seconds.isFinite, seconds >= 0 else { return "0 minutes" } |
||||
let totalMinutes = Int(seconds) / 60 |
||||
let hours = totalMinutes / 60 |
||||
let days = hours / 24 |
||||
let remainingHours = hours % 24 |
||||
|
||||
if days > 0 { |
||||
return "\(days) days, \(remainingHours) hours" |
||||
} else if hours > 0 { |
||||
let remainingMinutes = totalMinutes % 60 |
||||
return "\(hours) hours, \(remainingMinutes) minutes" |
||||
} else { |
||||
return "\(totalMinutes) minutes" |
||||
} |
||||
} |
||||
} |
||||
@ -1,79 +0,0 @@ |
||||
import Foundation |
||||
import Testing |
||||
@testable import Music |
||||
|
||||
struct SmartPlaylistTests { |
||||
// Creates a SmartPlaylist in memory and verifies its properties. |
||||
@Test func smartPlaylistProperties() throws { |
||||
let sp = SmartPlaylist( |
||||
id: nil, |
||||
name: "Miles Davis", |
||||
searchQuery: "miles davis", |
||||
createdAt: Date() |
||||
) |
||||
#expect(sp.name == "Miles Davis") |
||||
#expect(sp.searchQuery == "miles davis") |
||||
#expect(sp.isSmartPlaylist == true) |
||||
} |
||||
|
||||
// Creates a smart playlist in the database and fetches it back. |
||||
@Test func createAndFetchSmartPlaylist() throws { |
||||
let db = try DatabaseService(inMemory: true) |
||||
let sp = try db.createSmartPlaylist(name: "Jazz Vibes", searchQuery: "jazz") |
||||
|
||||
#expect(sp.id != nil) |
||||
#expect(sp.name == "Jazz Vibes") |
||||
#expect(sp.searchQuery == "jazz") |
||||
|
||||
let all = try db.fetchSmartPlaylists() |
||||
#expect(all.count == 1) |
||||
#expect(all[0].name == "Jazz Vibes") |
||||
} |
||||
|
||||
// Renames a smart playlist and verifies the new name persists. |
||||
@Test func renameSmartPlaylist() throws { |
||||
let db = try DatabaseService(inMemory: true) |
||||
let sp = try db.createSmartPlaylist(name: "Old Name", searchQuery: "old") |
||||
try db.renameSmartPlaylist(id: sp.id!, name: "New Name") |
||||
|
||||
let all = try db.fetchSmartPlaylists() |
||||
#expect(all[0].name == "New Name") |
||||
} |
||||
|
||||
// Updates the search query of a smart playlist. |
||||
@Test func updateSmartPlaylistQuery() throws { |
||||
let db = try DatabaseService(inMemory: true) |
||||
let sp = try db.createSmartPlaylist(name: "Jazz", searchQuery: "jazz") |
||||
try db.updateSmartPlaylistQuery(id: sp.id!, searchQuery: "jazz fusion") |
||||
|
||||
let all = try db.fetchSmartPlaylists() |
||||
#expect(all[0].searchQuery == "jazz fusion") |
||||
} |
||||
|
||||
// Deletes a smart playlist and verifies it's gone. |
||||
@Test func deleteSmartPlaylist() throws { |
||||
let db = try DatabaseService(inMemory: true) |
||||
let sp = try db.createSmartPlaylist(name: "To Delete", searchQuery: "delete") |
||||
try db.deleteSmartPlaylist(id: sp.id!) |
||||
|
||||
let all = try db.fetchSmartPlaylists() |
||||
#expect(all.isEmpty) |
||||
} |
||||
|
||||
// Verifies smart playlist tracks are computed via FTS5 search (not stored). |
||||
@Test func smartPlaylistReturnsDynamicResults() throws { |
||||
let db = try DatabaseService(inMemory: true) |
||||
var t1 = Track.fixture(fileURL: "/a.mp3", title: "Kind of Blue", artist: "Miles Davis") |
||||
var t2 = Track.fixture(fileURL: "/b.mp3", title: "Hotel California", artist: "Eagles") |
||||
var t3 = Track.fixture(fileURL: "/c.mp3", title: "Bitches Brew", artist: "Miles Davis") |
||||
try db.insert(&t1) |
||||
try db.insert(&t2) |
||||
try db.insert(&t3) |
||||
|
||||
// Smart playlist uses the existing fetchTracks with its searchQuery |
||||
let results = try db.fetchTracks(search: "miles davis", sortColumn: "title", ascending: true) |
||||
#expect(results.count == 2) |
||||
#expect(results[0].title == "Bitches Brew") |
||||
#expect(results[1].title == "Kind of Blue") |
||||
} |
||||
} |
||||
Loading…
Reference in new issue