Compare commits

..

9 Commits

Author SHA1 Message Date
Laurent d20bb2fef4 update config 1 month ago
Laurent 11d5f91a86 feat: improve player UX — scrubbing, keyboard shortcuts, now-playing indicator, Home button 1 month ago
Laurent a657075ef9 feat: integrate HomeView as default view in ContentView 1 month ago
Laurent 09be0460d4 feat: add HomeView with recently added list and library stats 1 month ago
Laurent 4a3dd23e57 feat: add database queries for home page (recently added, total duration, monthly additions) 1 month ago
Laurent ac4e421340 feat: wire smart playlists into UI — search save, bar, context menus, selection 1 month ago
Laurent e6ae6c5266 feat: add smart_playlists table migration and CRUD methods 1 month ago
Laurent 124a48a07c feat: add SmartPlaylist model with PlaylistRepresentable conformance 1 month ago
Laurent 7024be1cba feat: add PlaylistRepresentable protocol, conform Playlist 1 month ago
  1. 26
      Music.xcodeproj/project.pbxproj
  2. 128
      Music.xcodeproj/xcshareddata/xcschemes/Music.xcscheme
  3. 2
      Music/Assets.xcassets/AppIcon.appiconset/Contents.json
  4. BIN
      Music/Assets.xcassets/AppIcon.appiconset/icon_mu.png
  5. 279
      Music/ContentView.swift
  6. 4
      Music/Models/Playlist.swift
  7. 34
      Music/Models/SmartPlaylist.swift
  8. 2
      Music/Music.entitlements
  9. 7
      Music/MusicApp.swift
  10. 7
      Music/Protocols/PlaylistRepresentable.swift
  11. 71
      Music/Services/AudioService.swift
  12. 122
      Music/Services/DatabaseService.swift
  13. 136
      Music/ViewModels/PlaylistViewModel.swift
  14. 139
      Music/Views/HomeView.swift
  15. 37
      Music/Views/PlayerControlsView.swift
  16. 97
      Music/Views/PlaylistBarView.swift
  17. 12
      Music/Views/SearchBarView.swift
  18. 92
      Music/Views/TrackTableView.swift
  19. 62
      MusicTests/DatabaseServiceTests.swift
  20. 79
      MusicTests/SmartPlaylistTests.swift

@ -29,7 +29,7 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
C46B2C8D2FC2448700F95A24 /* Music.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Music.app; sourceTree = BUILT_PRODUCTS_DIR; };
C46B2C8D2FC2448700F95A24 /* Mumu.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mumu.app; sourceTree = BUILT_PRODUCTS_DIR; };
C46B2C9A2FC2448800F95A24 /* MusicTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MusicTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
C46B2CA42FC2448800F95A24 /* MusicUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MusicUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@ -108,7 +108,7 @@
C46B2C8E2FC2448700F95A24 /* Products */ = {
isa = PBXGroup;
children = (
C46B2C8D2FC2448700F95A24 /* Music.app */,
C46B2C8D2FC2448700F95A24 /* Mumu.app */,
C46B2C9A2FC2448800F95A24 /* MusicTests.xctest */,
C46B2CA42FC2448800F95A24 /* MusicUITests.xctest */,
);
@ -138,7 +138,7 @@
C46B2CBF2FC2449900F95A24 /* GRDB */,
);
productName = Music;
productReference = C46B2C8D2FC2448700F95A24 /* Music.app */;
productReference = C46B2C8D2FC2448700F95A24 /* Mumu.app */;
productType = "com.apple.product-type.application";
};
C46B2C992FC2448800F95A24 /* MusicTests */ = {
@ -423,16 +423,18 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Music/Music.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Mumu;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Music uses the microphone to identify songs with Shazam.";
@ -442,7 +444,9 @@
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.staxriver.mu;
PRODUCT_NAME = "$(TARGET_NAME)";
PRODUCT_MODULE_NAME = Music;
PRODUCT_NAME = Mumu;
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
@ -459,16 +463,18 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Music/Music.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Mumu;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Music uses the microphone to identify songs with Shazam.";
@ -478,7 +484,9 @@
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.staxriver.mu;
PRODUCT_NAME = "$(TARGET_NAME)";
PRODUCT_MODULE_NAME = Music;
PRODUCT_NAME = Mumu;
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
@ -506,7 +514,7 @@
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Music.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Music";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mumu.app/Contents/MacOS/Mumu";
};
name = Debug;
};
@ -527,7 +535,7 @@
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Music.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Music";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mumu.app/Contents/MacOS/Mumu";
};
name = Release;
};

@ -0,0 +1,128 @@
<?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>

@ -36,7 +36,6 @@
"size" : "256x256"
},
{
"filename" : "icon_mu.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
@ -47,6 +46,7 @@
"size" : "512x512"
},
{
"filename" : "icon_mu.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@ -8,21 +8,43 @@ struct ContentView: View {
var audio: AudioService
var playlist: PlaylistViewModel
var shazam: ShazamService
var db: DatabaseService
@Binding var showNewPlaylistAlert: Bool
@State private var showRenameAlert = false
@State private var showEditQueryAlert = false
@State private var playlistNameInput = ""
@State private var playlistToRename: Playlist?
@State private var editQueryInput = ""
@State private var itemToRename: (any PlaylistRepresentable)?
@State private var smartPlaylistToEdit: SmartPlaylist?
@State private var scrollToPlayingTrigger = UUID()
@State private var searchText = ""
@State private var keyMonitor: Any?
@State private var showHome = false
@State private var recentTracks: [Track] = []
@State private var totalDuration: Double = 0
@State private var monthlyAdditions: [MonthlyCount] = []
var body: some View {
VStack(spacing: 0) {
SearchBarView(
trackCount: playlist.selectedPlaylist != nil ? playlist.playlistTracks.count : library.trackCount,
searchText: $searchText,
trackCount: playlist.selectedItem != nil ? playlist.playlistTracks.count : library.trackCount,
onSearch: { text in
if !text.isEmpty {
showHome = false
}
if !text.isEmpty && library.searchText.isEmpty {
library.sortColumn = "album"
library.sortAscending = true
}
library.search(text)
if playlist.selectedPlaylist != nil {
playlist.search(text)
}
},
onSaveSearch: { query in
try? playlist.createSmartPlaylist(searchQuery: query)
},
isShazamListening: shazam.isListening,
onShazam: { shazam.isListening ? shazam.stopListening() : shazam.startListening() }
)
@ -38,9 +60,13 @@ struct ContentView: View {
.padding(.vertical, 4)
}
if let selected = playlist.selectedPlaylist {
if showHome || playlist.selectedItem != nil {
HStack(spacing: 4) {
Button(action: { playlist.deselectPlaylist() }) {
Button(action: {
playlist.deselectPlaylist()
searchText = ""
showHome = false
}) {
HStack(spacing: 2) {
Image(systemName: "chevron.left")
.font(.system(size: 10))
@ -55,7 +81,7 @@ struct ContentView: View {
.font(.system(size: 12))
.foregroundStyle(.quaternary)
Text(selected.name)
Text(showHome ? "Home" : (playlist.selectedItem?.name ?? ""))
.font(.system(size: 12, weight: .medium))
}
.padding(.horizontal, 12)
@ -63,71 +89,114 @@ struct ContentView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
TrackTableView(
tracks: playlist.selectedPlaylist != nil ? playlist.playlistTracks : library.tracks,
playingTrackId: player.currentTrack?.id,
onSort: { column in
if playlist.selectedPlaylist == nil {
library.sort(by: column)
if showHome && playlist.selectedItem == nil {
HomeView(
recentTracks: recentTracks,
trackCount: library.trackCount,
totalDuration: totalDuration,
monthlyAdditions: monthlyAdditions,
onTrackDoubleClick: { track in
player.setQueue(recentTracks)
player.play(track)
},
onShowAll: {
showHome = false
}
)
.onAppear { loadHomeData() }
} else {
TrackTableView(
tracks: playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks,
playingTrackId: player.currentTrack?.id,
sortColumn: playlist.selectedSmartPlaylist != nil ? playlist.sortColumn : library.sortColumn,
sortAscending: playlist.selectedSmartPlaylist != nil ? playlist.sortAscending : library.sortAscending,
onSort: { column in
if playlist.selectedSmartPlaylist != nil {
playlist.sort(by: column)
} else if playlist.selectedItem == nil {
library.sort(by: column)
}
},
onDoubleClick: { track in
let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks
player.setQueue(trackList)
player.play(track)
},
playlists: playlist.playlists,
lastUsedPlaylistName: playlist.lastUsedPlaylistName,
selectedPlaylist: playlist.selectedPlaylist,
onAddToPlaylist: { track, targetPlaylist in
try? playlist.addTrack(track, to: targetPlaylist)
},
onAddToLastPlaylist: { track in
try? playlist.addTrackToLastUsedPlaylist(track)
},
onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in
if let selected = playlist.selectedPlaylist {
try? playlist.removeTrack(track, from: selected)
}
} : nil,
onReorder: playlist.selectedPlaylist != nil ? { from, to in
if let selected = playlist.selectedPlaylist {
try? playlist.moveTrack(in: selected, from: from, to: to)
}
} : nil,
scrollToPlayingTrigger: scrollToPlayingTrigger
)
}
PlaylistBarView(
playlists: playlist.allPlaylists,
selectedItem: showHome ? nil : playlist.selectedItem,
isHomeSelected: showHome,
onHomeSelect: {
if showHome {
showHome = false
} else {
playlist.deselectPlaylist()
searchText = ""
showHome = true
}
},
onDoubleClick: { track in
let trackList = playlist.selectedPlaylist != nil ? playlist.playlistTracks : library.tracks
player.setQueue(trackList)
player.play(track)
onSelect: { item in
showHome = false
if item is SmartPlaylist {
playlist.sortColumn = "album"
playlist.sortAscending = true
}
playlist.selectItem(item)
if let smart = item as? SmartPlaylist {
searchText = smart.searchQuery
library.search(smart.searchQuery)
}
},
onPlayPause: { audio.togglePlayPause() },
playlists: playlist.playlists,
lastUsedPlaylistName: playlist.lastUsedPlaylistName,
selectedPlaylist: playlist.selectedPlaylist,
onAddToPlaylist: { track, targetPlaylist in
try? playlist.addTrack(track, to: targetPlaylist)
onDeselect: {
playlist.deselectPlaylist()
searchText = ""
},
onAddToLastPlaylist: { track in
try? playlist.addTrackToLastUsedPlaylist(track)
onRename: { item in
itemToRename = item
playlistNameInput = item.name
showRenameAlert = true
},
onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in
if let selected = playlist.selectedPlaylist {
try? playlist.removeTrack(track, from: selected)
onDelete: { item in
if let p = item as? Playlist {
try? playlist.deletePlaylist(p)
} else if let sp = item as? SmartPlaylist {
try? playlist.deleteSmartPlaylist(sp)
}
} : nil,
onReorder: playlist.selectedPlaylist != nil ? { from, to in
if let selected = playlist.selectedPlaylist {
try? playlist.moveTrack(in: selected, from: from, to: to)
}
} : nil
)
PlaylistBarView(
playlists: playlist.playlists,
selectedPlaylist: playlist.selectedPlaylist,
onSelect: { playlist.selectPlaylist($0) },
onDeselect: { playlist.deselectPlaylist() },
onRename: { p in
playlistToRename = p
playlistNameInput = p.name
showRenameAlert = true
},
onDelete: { p in
try? playlist.deletePlaylist(p)
onEditQuery: { smart in
smartPlaylistToEdit = smart
editQueryInput = smart.searchQuery
showEditQueryAlert = true
}
)
PlayerControlsView(
currentTrack: player.currentTrack,
isPlaying: audio.isPlaying,
currentTime: audio.currentTime,
duration: audio.duration,
volume: audio.volume,
isShuffled: player.isShuffled,
onPlayPause: { audio.togglePlayPause() },
onNext: { player.next() },
onPrevious: { player.previous() },
onSeek: { audio.seek(to: $0) },
onVolumeChange: { audio.volume = $0 },
onShuffleToggle: { player.toggleShuffle() }
)
playerControls
}
.onAppear { installKeyboardMonitor() }
.onDisappear { removeKeyboardMonitor() }
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
handleDrop(providers)
return true
@ -135,6 +204,9 @@ struct ContentView: View {
.onChange(of: audio.currentTime) { _, _ in
player.checkHalfway()
}
.onChange(of: library.trackCount) { _, _ in
if showHome { loadHomeData() }
}
.alert("New Playlist", isPresented: $showNewPlaylistAlert) {
TextField("Playlist name", text: $playlistNameInput)
Button("Cancel", role: .cancel) { playlistNameInput = "" }
@ -146,16 +218,36 @@ struct ContentView: View {
playlistNameInput = ""
}
}
.alert("Rename Playlist", isPresented: $showRenameAlert) {
TextField("Playlist name", text: $playlistNameInput)
.alert("Rename", isPresented: $showRenameAlert) {
TextField("Name", text: $playlistNameInput)
Button("Cancel", role: .cancel) { playlistNameInput = "" }
Button("Rename") {
let name = playlistNameInput.trimmingCharacters(in: .whitespaces)
if !name.isEmpty, let p = playlistToRename {
try? playlist.renamePlaylist(p, to: name)
if !name.isEmpty, let item = itemToRename {
if let p = item as? Playlist {
try? playlist.renamePlaylist(p, to: name)
} else if let sp = item as? SmartPlaylist {
try? playlist.renameSmartPlaylist(sp, to: name)
}
}
playlistNameInput = ""
playlistToRename = nil
itemToRename = nil
}
}
.alert("Edit Search Query", isPresented: $showEditQueryAlert) {
TextField("Search query", text: $editQueryInput)
Button("Cancel", role: .cancel) { editQueryInput = "" }
Button("Save") {
let query = editQueryInput.trimmingCharacters(in: .whitespaces)
if !query.isEmpty, let sp = smartPlaylistToEdit {
try? playlist.updateSmartPlaylistQuery(sp, to: query)
if playlist.selectedSmartPlaylist?.id == sp.id {
searchText = query
library.search(query)
}
}
editQueryInput = ""
smartPlaylistToEdit = nil
}
}
.alert("Song Identified", isPresented: Binding(
@ -180,6 +272,65 @@ struct ContentView: View {
}
}
private var playerControls: some View {
PlayerControlsView(
currentTrack: player.currentTrack,
isPlaying: audio.isPlaying,
currentTime: audio.currentTime,
duration: audio.duration,
volume: audio.volume,
isShuffled: player.isShuffled,
onPlayPause: { audio.togglePlayPause() },
onNext: { player.next() },
onPrevious: { player.previous() },
onSeek: { audio.seek(to: $0) },
onScrubStart: { audio.beginScrubbing() },
onScrub: { audio.scrub(to: $0) },
onScrubEnd: { audio.endScrubbing(at: $0) },
onVolumeChange: { audio.volume = $0 },
onShuffleToggle: { player.toggleShuffle() },
onNowPlayingTap: { scrollToPlayingTrigger = UUID() }
)
}
private func installKeyboardMonitor() {
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [audio, player] event in
guard event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty else {
return event
}
guard let responder = NSApp.keyWindow?.firstResponder,
!(responder is NSTextView) else {
return event
}
switch event.keyCode {
case 49: // space
audio.togglePlayPause()
return nil
case 123: // left arrow
player.previous()
return nil
case 124: // right arrow
player.next()
return nil
default:
return event
}
}
}
private func removeKeyboardMonitor() {
if let monitor = keyMonitor {
NSEvent.removeMonitor(monitor)
keyMonitor = nil
}
}
private func loadHomeData() {
recentTracks = (try? db.fetchRecentlyAdded(limit: 50)) ?? []
totalDuration = (try? db.totalDuration()) ?? 0
monthlyAdditions = (try? db.fetchMonthlyAdditions(months: 12)) ?? []
}
private func handleDrop(_ providers: [NSItemProvider]) {
for provider in providers {
provider.loadItem(forTypeIdentifier: "public.file-url") { data, _ in

@ -15,6 +15,10 @@ nonisolated extension Playlist: FetchableRecord, MutablePersistableRecord {
}
}
extension Playlist: PlaylistRepresentable {
var isSmartPlaylist: Bool { false }
}
#if DEBUG
extension Playlist {
static func fixture(

@ -0,0 +1,34 @@
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

@ -4,6 +4,8 @@
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.get-task-allow</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>

@ -27,6 +27,7 @@ struct MusicApp: App {
audio: audioService,
playlist: playlist,
shazam: shazamService,
db: db,
showNewPlaylistAlert: $showNewPlaylistAlert
)
} else if let error = initError {
@ -78,7 +79,7 @@ struct MusicApp: App {
Task {
await scanner.rescan(url)
}
} else {
} else if !isRunningTests {
DispatchQueue.main.async {
pickFolder()
}
@ -88,6 +89,10 @@ struct MusicApp: App {
}
}
private var isRunningTests: Bool {
ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
}
private func pickFolder() {
let panel = NSOpenPanel()
panel.canChooseFiles = false

@ -0,0 +1,7 @@
import Foundation
protocol PlaylistRepresentable: Identifiable, Hashable, Sendable {
var id: Int64? { get }
var name: String { get }
var isSmartPlaylist: Bool { get }
}

@ -20,6 +20,10 @@ final class AudioService {
private var timeObserver: Any?
private var endObserver: NSObjectProtocol?
private(set) var isScrubbing = false
private var seekInProgress = false
private var pendingSeekTime: Double?
var onTrackFinished: (() -> Void)?
func play(url: URL) {
@ -33,7 +37,7 @@ final class AudioService {
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
queue: .main
) { [weak self] time in
guard let self else { return }
guard let self, !self.isScrubbing else { return }
self.currentTime = time.seconds
if let dur = self.player?.currentItem?.duration, dur.isValid, !dur.isIndefinite {
self.duration = dur.seconds
@ -69,7 +73,70 @@ final class AudioService {
}
func seek(to time: Double) {
player?.seek(to: CMTime(seconds: time, preferredTimescale: 600))
let clamped = clampedTime(time)
currentTime = clamped
player?.seek(
to: CMTime(seconds: clamped, preferredTimescale: 600),
toleranceBefore: .zero,
toleranceAfter: .zero
)
}
func beginScrubbing() {
isScrubbing = true
}
func scrub(to time: Double) {
let clamped = clampedTime(time)
currentTime = clamped
chaseSeek(to: clamped)
}
func endScrubbing(at time: Double) {
let clamped = clampedTime(time)
currentTime = clamped
pendingSeekTime = nil
seekInProgress = false
player?.seek(
to: CMTime(seconds: clamped, preferredTimescale: 600),
toleranceBefore: .zero,
toleranceAfter: .zero
) { [weak self] _ in
DispatchQueue.main.async {
self?.isScrubbing = false
}
}
}
private func chaseSeek(to time: Double) {
pendingSeekTime = time
guard !seekInProgress else { return }
performPendingSeek()
}
private func performPendingSeek() {
guard let time = pendingSeekTime else { return }
pendingSeekTime = nil
seekInProgress = true
player?.seek(
to: CMTime(seconds: time, preferredTimescale: 600),
toleranceBefore: CMTime(seconds: 0.1, preferredTimescale: 600),
toleranceAfter: CMTime(seconds: 0.1, preferredTimescale: 600)
) { [weak self] _ in
DispatchQueue.main.async {
guard let self else { return }
self.seekInProgress = false
if self.pendingSeekTime != nil {
self.performPendingSeek()
}
}
}
}
private func clampedTime(_ time: Double) -> Double {
max(0, min(time, duration))
}
func stop() {

@ -1,6 +1,12 @@
import Foundation
import GRDB
// `nonisolated` opts this struct out of the project-wide `default-isolation = MainActor`.
nonisolated struct MonthlyCount: Sendable {
let month: Date
let count: Int
}
// `nonisolated` opts this class out of the project-wide `default-isolation = MainActor`
// setting so database operations run off the main actor and can be used from Sendable contexts.
nonisolated final class DatabaseService: Sendable {
@ -95,6 +101,15 @@ nonisolated final class DatabaseService: Sendable {
)
}
migrator.registerMigration("v3-create-smart-playlists") { db in
try db.create(table: "smart_playlists") { t in
t.autoIncrementedPrimaryKey("id")
t.column("name", .text).notNull().unique()
t.column("searchQuery", .text).notNull()
t.column("createdAt", .datetime).notNull()
}
}
try migrator.migrate(db)
}
@ -186,6 +201,63 @@ nonisolated final class DatabaseService: Sendable {
}
}
func fetchRecentlyAdded(limit: Int) throws -> [Track] {
try dbPool.read { db in
try Track.fetchAll(
db,
sql: "SELECT * FROM tracks ORDER BY dateAdded DESC LIMIT ?",
arguments: [limit]
)
}
}
func totalDuration() throws -> Double {
try dbPool.read { db in
try Double.fetchOne(db, sql: "SELECT COALESCE(SUM(duration), 0) FROM tracks") ?? 0
}
}
func fetchMonthlyAdditions(months: Int) throws -> [MonthlyCount] {
// Use a UTC calendar throughout: GRDB stores Date values as UTC ISO8601 strings,
// so SQLite's strftime('%Y-%m', ) returns UTC months. We align Swift's month
// boundaries to UTC as well to ensure consistent bucketing regardless of the
// device's local timezone.
var utcCalendar = Calendar(identifier: .gregorian)
utcCalendar.timeZone = TimeZone(identifier: "UTC")!
let now = Date()
let thisMonth = utcCalendar.dateInterval(of: .month, for: now)!.start
let startMonth = utcCalendar.date(byAdding: .month, value: -(months - 1), to: thisMonth)!
let rows: [(String, Int)] = try dbPool.read { db in
let rows = try Row.fetchAll(
db,
sql: """
SELECT strftime('%Y-%m', dateAdded) AS month, COUNT(*) AS cnt
FROM tracks
WHERE dateAdded >= ?
GROUP BY month
ORDER BY month ASC
""",
arguments: [startMonth]
)
return rows.map { ($0["month"] as String, $0["cnt"] as Int) }
}
let rowDict = Dictionary(uniqueKeysWithValues: rows)
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM"
formatter.timeZone = TimeZone(identifier: "UTC")!
var results: [MonthlyCount] = []
for i in 0..<months {
let month = utcCalendar.date(byAdding: .month, value: i, to: startMonth)!
let key = formatter.string(from: month)
let count = rowDict[key] ?? 0
results.append(MonthlyCount(month: month, count: count))
}
return results
}
// MARK: - Playlists
func createPlaylist(name: String) throws -> Playlist {
@ -340,4 +412,54 @@ nonisolated final class DatabaseService: Sendable {
)
}
}
// MARK: - Smart Playlists
func createSmartPlaylist(name: String, searchQuery: String) throws -> SmartPlaylist {
try dbPool.write { db in
var smartPlaylist = SmartPlaylist(
id: nil, name: name, searchQuery: searchQuery, createdAt: Date()
)
try smartPlaylist.insert(db)
return smartPlaylist
}
}
func renameSmartPlaylist(id: Int64, name: String) throws {
try dbPool.write { db in
try db.execute(
sql: "UPDATE smart_playlists SET name = ? WHERE id = ?",
arguments: [name, id]
)
}
}
func updateSmartPlaylistQuery(id: Int64, searchQuery: String) throws {
try dbPool.write { db in
try db.execute(
sql: "UPDATE smart_playlists SET searchQuery = ? WHERE id = ?",
arguments: [searchQuery, id]
)
}
}
func deleteSmartPlaylist(id: Int64) throws {
try dbPool.write { db in
try db.execute(sql: "DELETE FROM smart_playlists WHERE id = ?", arguments: [id])
}
}
func fetchSmartPlaylists() throws -> [SmartPlaylist] {
try dbPool.read { db in
try SmartPlaylist.fetchAll(
db, sql: "SELECT * FROM smart_playlists ORDER BY name COLLATE NOCASE ASC"
)
}
}
func fetchSmartPlaylists(db: Database) throws -> [SmartPlaylist] {
try SmartPlaylist.fetchAll(
db, sql: "SELECT * FROM smart_playlists ORDER BY name COLLATE NOCASE ASC"
)
}
}

@ -5,9 +5,24 @@ import GRDB
@Observable
final class PlaylistViewModel {
var playlists: [Playlist] = []
var selectedPlaylist: Playlist?
var smartPlaylists: [SmartPlaylist] = []
var selectedItem: (any PlaylistRepresentable)?
var playlistTracks: [Track] = []
var allPlaylists: [any PlaylistRepresentable] {
let regular: [any PlaylistRepresentable] = playlists
let smart: [any PlaylistRepresentable] = smartPlaylists
return (regular + smart).sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
var selectedPlaylist: Playlist? {
selectedItem as? Playlist
}
var selectedSmartPlaylist: SmartPlaylist? {
selectedItem as? SmartPlaylist
}
var lastUsedPlaylistId: Int64? {
get { UserDefaults.standard.object(forKey: "lastUsedPlaylistId") as? Int64 }
set { UserDefaults.standard.set(newValue, forKey: "lastUsedPlaylistId") }
@ -20,15 +35,21 @@ final class PlaylistViewModel {
private let db: DatabaseService
private var playlistsCancellable: AnyDatabaseCancellable?
private var smartPlaylistsCancellable: AnyDatabaseCancellable?
private var tracksCancellable: AnyDatabaseCancellable?
private var searchTask: Task<Void, Never>?
private var searchText = ""
var sortColumn = "title"
var sortAscending = true
init(db: DatabaseService) {
self.db = db
observePlaylists()
observeSmartPlaylists()
}
// MARK: - Regular Playlists
func createPlaylist(name: String) throws {
_ = try db.createPlaylist(name: name)
}
@ -71,29 +92,82 @@ final class PlaylistViewModel {
try db.reorderPlaylistTrack(playlistId: playlistId, fromPosition: source, toPosition: destination)
}
func search(_ text: String) {
searchText = text
searchTask?.cancel()
searchTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(150))
guard !Task.isCancelled else { return }
self?.observePlaylistTracks()
// MARK: - Smart Playlists
func createSmartPlaylist(searchQuery: String) throws {
let name = searchQuery
.split(separator: " ")
.map { $0.prefix(1).uppercased() + $0.dropFirst().lowercased() }
.joined(separator: " ")
_ = try db.createSmartPlaylist(name: name, searchQuery: searchQuery)
}
func renameSmartPlaylist(_ smartPlaylist: SmartPlaylist, to name: String) throws {
guard let id = smartPlaylist.id else { return }
try db.renameSmartPlaylist(id: id, name: name)
}
func updateSmartPlaylistQuery(_ smartPlaylist: SmartPlaylist, to query: String) throws {
guard let id = smartPlaylist.id else { return }
try db.updateSmartPlaylistQuery(id: id, searchQuery: query)
if selectedSmartPlaylist?.id == id {
observeSmartPlaylistTracks(searchQuery: query)
}
}
func selectPlaylist(_ playlist: Playlist) {
selectedPlaylist = playlist
observePlaylistTracks()
func deleteSmartPlaylist(_ smartPlaylist: SmartPlaylist) throws {
guard let id = smartPlaylist.id else { return }
if selectedSmartPlaylist?.id == id {
deselectPlaylist()
}
try db.deleteSmartPlaylist(id: id)
}
// MARK: - Selection
func selectItem(_ item: any PlaylistRepresentable) {
selectedItem = item
if item is Playlist {
observePlaylistTracks()
} else if let smart = item as? SmartPlaylist {
observeSmartPlaylistTracks(searchQuery: smart.searchQuery)
}
}
func deselectPlaylist() {
selectedPlaylist = nil
selectedItem = nil
tracksCancellable?.cancel()
tracksCancellable = nil
playlistTracks = []
searchText = ""
}
func sort(by column: String) {
if sortColumn == column {
sortAscending.toggle()
} else {
sortColumn = column
sortAscending = true
}
if let smart = selectedSmartPlaylist {
observeSmartPlaylistTracks(searchQuery: smart.searchQuery)
}
}
func search(_ text: String) {
searchText = text
searchTask?.cancel()
searchTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(150))
guard !Task.isCancelled else { return }
if self?.selectedPlaylist != nil {
self?.observePlaylistTracks()
}
}
}
// MARK: - Observation
private func observePlaylists() {
let observation = ValueObservation.tracking { [db] dbAccess in
try db.fetchPlaylists(db: dbAccess)
@ -101,7 +175,28 @@ final class PlaylistViewModel {
playlistsCancellable = observation.start(
in: db.dbPool,
onError: { error in print("Playlists observation error: \(error)") },
onChange: { [weak self] playlists in self?.playlists = playlists }
onChange: { [weak self] playlists in
self?.playlists = playlists
if let selectedId = self?.selectedPlaylist?.id {
self?.selectedItem = playlists.first(where: { $0.id == selectedId })
}
}
)
}
private func observeSmartPlaylists() {
let observation = ValueObservation.tracking { [db] dbAccess in
try db.fetchSmartPlaylists(db: dbAccess)
}
smartPlaylistsCancellable = observation.start(
in: db.dbPool,
onError: { error in print("Smart playlists observation error: \(error)") },
onChange: { [weak self] smartPlaylists in
self?.smartPlaylists = smartPlaylists
if let selectedId = self?.selectedSmartPlaylist?.id {
self?.selectedItem = smartPlaylists.first(where: { $0.id == selectedId })
}
}
)
}
@ -119,4 +214,19 @@ final class PlaylistViewModel {
onChange: { [weak self] tracks in self?.playlistTracks = tracks }
)
}
private func observeSmartPlaylistTracks(searchQuery: String) {
tracksCancellable?.cancel()
let col = sortColumn
let asc = sortAscending
let observation = ValueObservation.tracking { [db] dbAccess in
try db.fetchTracks(db: dbAccess, search: searchQuery, sortColumn: col, ascending: asc)
}
tracksCancellable = observation.start(
in: db.dbPool,
onError: { error in print("Smart playlist tracks observation error: \(error)") },
onChange: { [weak self] tracks in self?.playlistTracks = tracks }
)
}
}

@ -0,0 +1,139 @@
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"
}
}
}

@ -11,8 +11,15 @@ struct PlayerControlsView: View {
let onNext: () -> Void
let onPrevious: () -> Void
let onSeek: (Double) -> Void
let onScrubStart: () -> Void
let onScrub: (Double) -> Void
let onScrubEnd: (Double) -> Void
let onVolumeChange: (Float) -> Void
let onShuffleToggle: () -> Void
let onNowPlayingTap: () -> Void
@State private var isDragging = false
@State private var dragValue: Double = 0
var body: some View {
HStack(spacing: 0) {
@ -60,6 +67,12 @@ struct PlayerControlsView: View {
}
}
}
.contentShape(Rectangle())
.onTapGesture {
if currentTrack != nil {
onNowPlayingTap()
}
}
}
private var transportSection: some View {
@ -81,6 +94,7 @@ struct PlayerControlsView: View {
Button(action: onPlayPause) {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 22))
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
@ -95,17 +109,32 @@ struct PlayerControlsView: View {
}
HStack(spacing: 8) {
Text(Self.formatTime(currentTime))
Text(Self.formatTime(isDragging ? dragValue : currentTime))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 45, alignment: .trailing)
Slider(
value: Binding(
get: { currentTime },
set: { onSeek($0) }
get: { isDragging ? dragValue : currentTime },
set: { newValue in
dragValue = newValue
if isDragging {
onScrub(newValue)
}
}
),
in: 0...max(duration, 1)
in: 0...max(duration, 1),
onEditingChanged: { editing in
if editing {
isDragging = true
dragValue = currentTime
onScrubStart()
} else {
onScrubEnd(dragValue)
isDragging = false
}
}
)
.controlSize(.small)

@ -1,58 +1,87 @@
import SwiftUI
struct PlaylistBarView: View {
var playlists: [Playlist]
var selectedPlaylist: Playlist?
var onSelect: (Playlist) -> Void
var playlists: [any PlaylistRepresentable]
var selectedItem: (any PlaylistRepresentable)?
var isHomeSelected: Bool
var onHomeSelect: () -> Void
var onSelect: (any PlaylistRepresentable) -> Void
var onDeselect: () -> Void
var onRename: (Playlist) -> Void
var onDelete: (Playlist) -> Void
var onRename: (any PlaylistRepresentable) -> Void
var onDelete: (any PlaylistRepresentable) -> Void
var onEditQuery: (SmartPlaylist) -> Void
var body: some View {
if !playlists.isEmpty {
FlowLayout(spacing: 6) {
ForEach(playlists) { playlist in
PlaylistButton(
name: playlist.name,
isSelected: selectedPlaylist?.id == playlist.id,
action: {
if selectedPlaylist?.id == playlist.id {
onDeselect()
} else {
onSelect(playlist)
}
FlowLayout(spacing: 6) {
PlaylistButton(
name: "Home",
isSelected: isHomeSelected,
isSmart: false,
icon: "house.fill",
action: onHomeSelect
)
ForEach(playlists, id: \.id) { item in
PlaylistButton(
name: item.name,
isSelected: selectedItem?.id == item.id,
isSmart: item.isSmartPlaylist,
action: {
if selectedItem?.id == item.id {
onDeselect()
} else {
onSelect(item)
}
)
.contextMenu {
Button("Rename...") { onRename(playlist) }
Button("Delete") { onDelete(playlist) }
}
)
.contextMenu {
Button("Rename...") { onRename(item) }
if let smart = item as? SmartPlaylist {
Button("Edit Search Query...") { onEditQuery(smart) }
}
Button("Delete") { onDelete(item) }
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
}
private struct PlaylistButton: View {
let name: String
let isSelected: Bool
let isSmart: Bool
var icon: String? = nil
let action: () -> Void
private var tintColor: Color {
isSmart ? .purple : .accentColor
}
private var inactiveColor: Color {
isSmart ? .purple.opacity(0.7) : .secondary
}
var body: some View {
Button(action: action) {
Text(name)
.font(.system(size: 11))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(isSelected ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1))
.foregroundStyle(isSelected ? Color.accentColor : .secondary)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1)
)
.cornerRadius(4)
HStack(spacing: 4) {
if let icon {
Image(systemName: icon)
.font(.system(size: 10))
}
Text(name)
}
.font(.system(size: 11))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(isSelected ? tintColor.opacity(0.2) : Color.secondary.opacity(0.1))
.foregroundStyle(isSelected ? tintColor : inactiveColor)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(isSelected ? tintColor : Color.secondary.opacity(0.3), lineWidth: 1)
)
.cornerRadius(4)
}
.buttonStyle(.plain)
}

@ -1,9 +1,10 @@
import SwiftUI
struct SearchBarView: View {
@State private var searchText = ""
@Binding var searchText: String
let trackCount: Int
let onSearch: (String) -> Void
let onSaveSearch: (String) -> Void
let isShazamListening: Bool
let onShazam: () -> Void
@ -26,6 +27,15 @@ struct SearchBarView: View {
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
Button {
onSaveSearch(searchText)
} label: {
Image(systemName: "plus.circle")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Save as smart playlist")
}
}
.padding(8)

@ -38,9 +38,10 @@ private func loadVisibleColumnIds() -> Set<String> {
struct TrackTableView: NSViewRepresentable {
let tracks: [Track]
let playingTrackId: Int64?
let sortColumn: String
let sortAscending: Bool
let onSort: (String) -> Void
let onDoubleClick: (Track) -> Void
let onPlayPause: () -> Void
var playlists: [Playlist]
var lastUsedPlaylistName: String?
var selectedPlaylist: Playlist?
@ -48,6 +49,7 @@ struct TrackTableView: NSViewRepresentable {
var onAddToLastPlaylist: ((Track) -> Void)?
var onRemoveFromPlaylist: ((Track) -> Void)?
var onReorder: ((Int, Int) -> Void)?
var scrollToPlayingTrigger: UUID = UUID()
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
@ -63,6 +65,13 @@ struct TrackTableView: NSViewRepresentable {
let visibleIds = loadVisibleColumnIds()
let nowPlayingColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("nowPlaying"))
nowPlayingColumn.title = ""
nowPlayingColumn.width = 20
nowPlayingColumn.minWidth = 20
nowPlayingColumn.maxWidth = 20
tableView.addTableColumn(nowPlayingColumn)
for col in columnDefinitions {
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(col.id))
column.title = col.title
@ -76,12 +85,13 @@ struct TrackTableView: NSViewRepresentable {
tableView.addTableColumn(column)
}
tableView.sortDescriptors = [NSSortDescriptor(key: sortColumn, ascending: sortAscending)]
tableView.delegate = context.coordinator
tableView.dataSource = context.coordinator
tableView.doubleAction = #selector(Coordinator.handleDoubleClick(_:))
tableView.target = context.coordinator
tableView.enterAction = #selector(Coordinator.handleEnterKey(_:))
tableView.spaceAction = #selector(Coordinator.handleSpaceKey(_:))
context.coordinator.tableView = tableView
@ -116,9 +126,15 @@ struct TrackTableView: NSViewRepresentable {
let tracksChanged = context.coordinator.tracks != tracks
let playingChanged = context.coordinator.playingTrackId != playingTrackId
let scrollTriggered = context.coordinator.lastScrollTrigger != scrollToPlayingTrigger
context.coordinator.parent = self
let expectedDescriptor = NSSortDescriptor(key: sortColumn, ascending: sortAscending)
if tableView.sortDescriptors.first != expectedDescriptor {
tableView.sortDescriptors = [expectedDescriptor]
}
if context.coordinator.parent.onReorder != nil {
if tableView.registeredDraggedTypes.isEmpty || !tableView.registeredDraggedTypes.contains(.string) {
tableView.registerForDraggedTypes([.string])
@ -128,6 +144,15 @@ struct TrackTableView: NSViewRepresentable {
tableView.unregisterDraggedTypes()
}
if scrollTriggered {
context.coordinator.lastScrollTrigger = scrollToPlayingTrigger
if let playingId = playingTrackId,
let row = context.coordinator.tracks.firstIndex(where: { $0.id == playingId }) {
tableView.scrollRowToVisible(row)
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
}
}
guard tracksChanged || playingChanged else { return }
let selectedIds = Set(tableView.selectedRowIndexes.compactMap { idx -> Int64? in
@ -156,6 +181,7 @@ struct TrackTableView: NSViewRepresentable {
var parent: TrackTableView
var tracks: [Track] = []
var playingTrackId: Int64?
var lastScrollTrigger: UUID = UUID()
weak var tableView: NSTableView?
init(_ parent: TrackTableView) {
@ -171,6 +197,36 @@ struct TrackTableView: NSViewRepresentable {
let track = tracks[row]
let colId = tableColumn?.identifier.rawValue ?? ""
if colId == "nowPlaying" {
let cellId = NSUserInterfaceItemIdentifier("Cell_nowPlaying")
let cellView: NSTableCellView
if let existing = tableView.makeView(withIdentifier: cellId, owner: nil) as? NSTableCellView {
cellView = existing
} else {
cellView = NSTableCellView()
cellView.identifier = cellId
let imageView = NSImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.imageScaling = .scaleProportionallyDown
cellView.addSubview(imageView)
cellView.imageView = imageView
NSLayoutConstraint.activate([
imageView.centerXAnchor.constraint(equalTo: cellView.centerXAnchor),
imageView.centerYAnchor.constraint(equalTo: cellView.centerYAnchor),
imageView.widthAnchor.constraint(equalToConstant: 12),
imageView.heightAnchor.constraint(equalToConstant: 12),
])
}
let isPlaying = track.id == parent.playingTrackId
if isPlaying {
cellView.imageView?.image = NSImage(systemSymbolName: "speaker.fill", accessibilityDescription: "Now Playing")
cellView.imageView?.contentTintColor = .secondaryLabelColor
} else {
cellView.imageView?.image = nil
}
return cellView
}
let cellId = NSUserInterfaceItemIdentifier("Cell_\(colId)")
let cellView: NSTableCellView
if let existing = tableView.makeView(withIdentifier: cellId, owner: nil) as? NSTableCellView {
@ -193,76 +249,59 @@ struct TrackTableView: NSViewRepresentable {
let cell = cellView.textField!
let isPlaying = track.id == parent.playingTrackId
cell.font = isPlaying ? .boldSystemFont(ofSize: 12) : .systemFont(ofSize: 12)
cell.textColor = .secondaryLabelColor
cell.textColor = .labelColor
cell.alignment = .left
switch colId {
case "title":
cell.stringValue = track.title
cell.textColor = .labelColor
case "artist":
cell.stringValue = track.artist
case "albumArtist":
cell.stringValue = track.albumArtist
case "album":
cell.stringValue = track.album
cell.textColor = .tertiaryLabelColor
case "composer":
cell.stringValue = track.composer
case "genre":
cell.stringValue = track.genre
cell.textColor = .tertiaryLabelColor
case "year":
cell.stringValue = track.year.map { String($0) } ?? ""
cell.textColor = .tertiaryLabelColor
cell.alignment = .right
case "bpm":
cell.stringValue = track.bpm.map { String($0) } ?? ""
cell.textColor = .tertiaryLabelColor
cell.alignment = .right
case "trackNumber":
cell.stringValue = track.trackNumber.map { String($0) } ?? ""
cell.textColor = .tertiaryLabelColor
cell.alignment = .right
case "discNumber":
cell.stringValue = track.discNumber.map { String($0) } ?? ""
cell.textColor = .tertiaryLabelColor
cell.alignment = .right
case "duration":
cell.stringValue = Self.formatDuration(track.duration)
cell.textColor = .tertiaryLabelColor
cell.alignment = .right
case "playCount":
cell.stringValue = track.playCount > 0 ? "\(track.playCount)" : ""
cell.textColor = .tertiaryLabelColor
cell.alignment = .right
case "lastPlayedAt":
cell.stringValue = track.lastPlayedAt.map { Self.formatDate($0) } ?? ""
cell.textColor = .tertiaryLabelColor
case "rating":
cell.stringValue = track.rating > 0 ? String(repeating: "", count: track.rating) : ""
cell.textColor = .tertiaryLabelColor
cell.alignment = .right
case "dateAdded":
cell.stringValue = Self.formatDate(track.dateAdded)
cell.textColor = .tertiaryLabelColor
case "dateModified":
cell.stringValue = Self.formatDate(track.dateModified)
cell.textColor = .tertiaryLabelColor
case "fileFormat":
cell.stringValue = track.fileFormat.uppercased()
cell.textColor = .tertiaryLabelColor
case "bitrate":
cell.stringValue = track.bitrate.map { "\($0) kbps" } ?? ""
cell.textColor = .tertiaryLabelColor
cell.alignment = .right
case "sampleRate":
cell.stringValue = track.sampleRate.map { Self.formatSampleRate($0) } ?? ""
cell.textColor = .tertiaryLabelColor
cell.alignment = .right
case "fileSize":
cell.stringValue = Self.formatFileSize(track.fileSize)
cell.textColor = .tertiaryLabelColor
cell.alignment = .right
default:
cell.stringValue = ""
@ -272,9 +311,9 @@ struct TrackTableView: NSViewRepresentable {
}
func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) {
if let sort = tableView.sortDescriptors.first, let key = sort.key {
parent.onSort(key)
}
guard let sort = tableView.sortDescriptors.first, let key = sort.key else { return }
guard key != parent.sortColumn || sort.ascending != parent.sortAscending else { return }
parent.onSort(key)
}
@objc func handleDoubleClick(_ sender: NSTableView) {
@ -289,10 +328,6 @@ struct TrackTableView: NSViewRepresentable {
parent.onDoubleClick(tracks[row])
}
@objc func handleSpaceKey(_ sender: NSTableView) {
parent.onPlayPause()
}
// MARK: - Context Menu
func menuNeedsUpdate(_ menu: NSMenu) {
@ -422,13 +457,10 @@ struct TrackTableView: NSViewRepresentable {
private final class PlayableTableView: NSTableView {
var enterAction: Selector?
var spaceAction: Selector?
override func keyDown(with event: NSEvent) {
if event.keyCode == 36 || event.keyCode == 76, let enterAction, let target {
NSApp.sendAction(enterAction, to: target, from: self)
} else if event.keyCode == 49, let spaceAction, let target {
NSApp.sendAction(spaceAction, to: target, from: self)
} else {
super.keyDown(with: event)
}

@ -227,4 +227,66 @@ struct DatabaseServiceTests {
let tracks = try db.fetchPlaylistTracks(playlistId: playlist.id!)
#expect(tracks.count == 1)
}
// Inserts three tracks with different dateAdded values and verifies fetchRecentlyAdded
// returns them in descending order capped to the given limit.
@Test func fetchRecentlyAdded() throws {
let db = try DatabaseService(inMemory: true)
var t1 = Track.fixture(fileURL: "/a.mp3", title: "Old", dateAdded: Date(timeIntervalSince1970: 1000))
var t2 = Track.fixture(fileURL: "/b.mp3", title: "Mid", dateAdded: Date(timeIntervalSince1970: 2000))
var t3 = Track.fixture(fileURL: "/c.mp3", title: "New", dateAdded: Date(timeIntervalSince1970: 3000))
try db.insert(&t1)
try db.insert(&t2)
try db.insert(&t3)
let recent = try db.fetchRecentlyAdded(limit: 2)
#expect(recent.count == 2)
#expect(recent[0].title == "New")
#expect(recent[1].title == "Mid")
}
// Inserts two tracks with known durations and verifies totalDuration sums them correctly.
@Test func totalDuration() throws {
let db = try DatabaseService(inMemory: true)
var t1 = Track.fixture(fileURL: "/a.mp3", duration: 120.0)
var t2 = Track.fixture(fileURL: "/b.mp3", duration: 300.5)
try db.insert(&t1)
try db.insert(&t2)
let total = try db.totalDuration()
#expect(abs(total - 420.5) < 0.01)
}
// Verifies totalDuration returns 0 when the library is empty.
@Test func totalDurationEmptyLibrary() throws {
let db = try DatabaseService(inMemory: true)
let total = try db.totalDuration()
#expect(total == 0)
}
// Inserts tracks in different months and verifies fetchMonthlyAdditions returns
// the correct per-month counts covering the requested range including empty months.
// Uses a UTC calendar to match the implementation, which uses UTC month boundaries
// because GRDB stores dates as UTC ISO8601 strings.
@Test func fetchMonthlyAdditions() throws {
let db = try DatabaseService(inMemory: true)
var utcCal = Calendar(identifier: .gregorian)
utcCal.timeZone = TimeZone(identifier: "UTC")!
let now = Date()
let thisMonth = utcCal.dateInterval(of: .month, for: now)!.start
let twoMonthsAgo = utcCal.date(byAdding: .month, value: -2, to: thisMonth)!
var t1 = Track.fixture(fileURL: "/a.mp3", dateAdded: thisMonth)
var t2 = Track.fixture(fileURL: "/b.mp3", dateAdded: thisMonth.addingTimeInterval(86400))
var t3 = Track.fixture(fileURL: "/c.mp3", dateAdded: twoMonthsAgo)
try db.insert(&t1)
try db.insert(&t2)
try db.insert(&t3)
let results = try db.fetchMonthlyAdditions(months: 3)
#expect(results.count == 3)
#expect(results[0].count == 1)
#expect(results[1].count == 0)
#expect(results[2].count == 2)
}
}

@ -0,0 +1,79 @@
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…
Cancel
Save