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