commit 83acfc63c9a459c1e5121ac3d4adb4836b62ce91 Author: Mark Gregory Date: Tue Nov 3 12:53:05 2026 +0000 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61ded96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build/ +build-release/ +dist/ +.DS_Store +xcuserdata/ +*.xcuserstate diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/KJamsClone.iml b/.idea/KJamsClone.iml new file mode 100644 index 0000000..6102194 --- /dev/null +++ b/.idea/KJamsClone.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..4dc8b7b --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/Brand/ICON-PROMPT.txt b/Brand/ICON-PROMPT.txt new file mode 100644 index 0000000..4af56be --- /dev/null +++ b/Brand/ICON-PROMPT.txt @@ -0,0 +1,3 @@ +Generated with the built-in image_gen tool. The user approved this icon. Original output is 1254 × 1254 pixels; native macOS icon sizes are downsampled from that master. + +Use case: logo-brand. Asset type: high-resolution macOS application icon for KJMix, a professional karaoke hosting and audio mixing app. Create one original polished colourful icon, 2048 by 2048 square if available: a distinctive microphone and flowing multicolour audio waveform interlock into a subtle K/M monogram on a deep midnight rounded-square tile. Saturated cyan, violet, magenta, warm coral and gold accents with elegant dimensional depth, crisp silhouette, restrained studio-quality lighting, readable at tiny Dock sizes. Centre the mark generously within the tile, no external drop shadow, no surrounding mockup, no text or letters as typography, no watermark, no other brand logos. Fill the square canvas with the tile's dark background; retain clean icon edges. Output a single final icon. diff --git a/Brand/KJMix-icon-master.png b/Brand/KJMix-icon-master.png new file mode 100644 index 0000000..03199f7 Binary files /dev/null and b/Brand/KJMix-icon-master.png differ diff --git a/Fixtures/Aerosmith - One Way Street.cdg b/Fixtures/Aerosmith - One Way Street.cdg new file mode 100644 index 0000000..080aeb7 Binary files /dev/null and b/Fixtures/Aerosmith - One Way Street.cdg differ diff --git a/Fixtures/Aerosmith - One Way Street.mp3 b/Fixtures/Aerosmith - One Way Street.mp3 new file mode 100644 index 0000000..c575680 Binary files /dev/null and b/Fixtures/Aerosmith - One Way Street.mp3 differ diff --git a/Fixtures/Demo.cdg b/Fixtures/Demo.cdg new file mode 100644 index 0000000..bed38f3 Binary files /dev/null and b/Fixtures/Demo.cdg differ diff --git a/Fixtures/Demo.mp3 b/Fixtures/Demo.mp3 new file mode 100644 index 0000000..dc3861b Binary files /dev/null and b/Fixtures/Demo.mp3 differ diff --git a/Fixtures/Demo.wav b/Fixtures/Demo.wav new file mode 100644 index 0000000..79d9c43 Binary files /dev/null and b/Fixtures/Demo.wav differ diff --git a/Fixtures/Demo.zip b/Fixtures/Demo.zip new file mode 100644 index 0000000..d9ac7d6 Binary files /dev/null and b/Fixtures/Demo.zip differ diff --git a/Fixtures/Irish Traditional - The Fields Of Athenry.zip b/Fixtures/Irish Traditional - The Fields Of Athenry.zip new file mode 100644 index 0000000..92330d9 Binary files /dev/null and b/Fixtures/Irish Traditional - The Fields Of Athenry.zip differ diff --git a/Fixtures/StoreCatalogue.example.json b/Fixtures/StoreCatalogue.example.json new file mode 100644 index 0000000..52f4ef0 --- /dev/null +++ b/Fixtures/StoreCatalogue.example.json @@ -0,0 +1,11 @@ +[ + { + "id": "original-demo-1", + "title": "Original Demo Song", + "artist": "KJMix Pro Test", + "album": "Example catalogue only", + "format": "MP3+G", + "price": "Example — not for sale", + "url": "https://example.com/karaoke-demo" + } +] diff --git a/Fixtures/TEST-MEDIA.txt b/Fixtures/TEST-MEDIA.txt new file mode 100644 index 0000000..7a4cec3 --- /dev/null +++ b/Fixtures/TEST-MEDIA.txt @@ -0,0 +1,3 @@ +Original synthetic 20-second low-volume tone melody and original test text. No vocals or commercial song. Free to use. Import TestTrack.zip, or keep TestTrack.mp3 and TestTrack.cdg together and import the MP3. CDG has timed white-to-yellow word highlighting. Test pause, seek, tempo and key changes. Demo.* remains the original 4-second regression fixture. Unsafe.zip is a deliberately invalid traversal test fixture, not playable media. + +VideoDemo.mp4: original 4-second FFmpeg testsrc2 colour/motion pattern plus Demo synthetic tone, H.264/AAC. No commercial content. diff --git a/Fixtures/TestTrack.cdg b/Fixtures/TestTrack.cdg new file mode 100644 index 0000000..ecfda74 Binary files /dev/null and b/Fixtures/TestTrack.cdg differ diff --git a/Fixtures/TestTrack.mp3 b/Fixtures/TestTrack.mp3 new file mode 100644 index 0000000..197fdc9 Binary files /dev/null and b/Fixtures/TestTrack.mp3 differ diff --git a/Fixtures/TestTrack.wav b/Fixtures/TestTrack.wav new file mode 100644 index 0000000..6fb39b1 Binary files /dev/null and b/Fixtures/TestTrack.wav differ diff --git a/Fixtures/TestTrack.zip b/Fixtures/TestTrack.zip new file mode 100644 index 0000000..533073e Binary files /dev/null and b/Fixtures/TestTrack.zip differ diff --git a/Fixtures/Traditional Christmas - Rudolph The Red-Nosed Reindeer.zip b/Fixtures/Traditional Christmas - Rudolph The Red-Nosed Reindeer.zip new file mode 100644 index 0000000..ee3d1cd Binary files /dev/null and b/Fixtures/Traditional Christmas - Rudolph The Red-Nosed Reindeer.zip differ diff --git a/Fixtures/Traditional Christmas - Santa Claus Is Coming To Town.zip b/Fixtures/Traditional Christmas - Santa Claus Is Coming To Town.zip new file mode 100644 index 0000000..c80024b Binary files /dev/null and b/Fixtures/Traditional Christmas - Santa Claus Is Coming To Town.zip differ diff --git a/Fixtures/Traditional Christmas Carol - Silent Night.zip b/Fixtures/Traditional Christmas Carol - Silent Night.zip new file mode 100644 index 0000000..29da8c0 Binary files /dev/null and b/Fixtures/Traditional Christmas Carol - Silent Night.zip differ diff --git a/Fixtures/Unsafe.zip b/Fixtures/Unsafe.zip new file mode 100644 index 0000000..3142424 Binary files /dev/null and b/Fixtures/Unsafe.zip differ diff --git a/Fixtures/VideoDemo.mp4 b/Fixtures/VideoDemo.mp4 new file mode 100644 index 0000000..5fd9f1b Binary files /dev/null and b/Fixtures/VideoDemo.mp4 differ diff --git a/KJMix Pro.xcodeproj/project.pbxproj b/KJMix Pro.xcodeproj/project.pbxproj new file mode 100644 index 0000000..aae6e23 --- /dev/null +++ b/KJMix Pro.xcodeproj/project.pbxproj @@ -0,0 +1,364 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 000000000000000000000065 /* KJMixProApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000001 /* KJMixProApp.swift */; }; + 000000000000000000000066 /* Library.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000002 /* Library.swift */; }; + 000000000000000000000067 /* CDG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000003 /* CDG.swift */; }; + 000000000000000000000068 /* Importer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000004 /* Importer.swift */; }; + 000000000000000000000069 /* Player.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000005 /* Player.swift */; }; + A00000000000000000000004 /* KJamsCloneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000003 /* KJamsCloneTests.swift */; }; + AB0000000000000000000002 /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = AB0000000000000000000001 /* Fixtures */; }; + AC0000000000000000000001 /* Hosting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0000000000000000000001 /* Hosting.swift */; }; + AC0000000000000000000002 /* SingerServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0000000000000000000002 /* SingerServer.swift */; }; + AC0000000000000000000003 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0000000000000000000003 /* Metadata.swift */; }; + AE0000000000000000000002 /* LibraryScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0000000000000000000001 /* LibraryScanner.swift */; }; + AE0000000000000000000011 /* DJWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0000000000000000000010 /* DJWorkspace.swift */; }; + AF0000000000000000000002 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AF0000000000000000000001 /* Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + D00000000000000000000003 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F00000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D00000000000000000000001; + remoteInfo = KJamsClone; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 000000000000000000000001 /* KJMixProApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KJMixProApp.swift; sourceTree = ""; }; + 000000000000000000000002 /* Library.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Library.swift; sourceTree = ""; }; + 000000000000000000000003 /* CDG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CDG.swift; sourceTree = ""; }; + 000000000000000000000004 /* Importer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Importer.swift; sourceTree = ""; }; + 000000000000000000000005 /* Player.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Player.swift; sourceTree = ""; }; + A00000000000000000000001 /* KJMix Pro.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; path = "KJMix Pro.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + A00000000000000000000002 /* KJMixProTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = KJMixProTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A00000000000000000000003 /* KJamsCloneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KJamsCloneTests.swift; sourceTree = ""; }; + AB0000000000000000000001 /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = ""; }; + AD0000000000000000000001 /* Hosting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hosting.swift; sourceTree = ""; }; + AD0000000000000000000002 /* SingerServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingerServer.swift; sourceTree = ""; }; + AD0000000000000000000003 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = ""; }; + AE0000000000000000000001 /* LibraryScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryScanner.swift; sourceTree = ""; }; + AE0000000000000000000010 /* DJWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DJWorkspace.swift; sourceTree = ""; }; + AF0000000000000000000001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + B00000000000000000000001 = { + isa = PBXGroup; + children = ( + AB0000000000000000000001 /* Fixtures */, + B00000000000000000000002 /* KJamsClone */, + B00000000000000000000003 /* KJamsCloneTests */, + B00000000000000000000004 /* Products */, + ); + sourceTree = ""; + }; + B00000000000000000000002 /* KJamsClone */ = { + isa = PBXGroup; + children = ( + 000000000000000000000001 /* KJMixProApp.swift */, + 000000000000000000000002 /* Library.swift */, + 000000000000000000000003 /* CDG.swift */, + 000000000000000000000004 /* Importer.swift */, + 000000000000000000000005 /* Player.swift */, + AF0000000000000000000001 /* Assets.xcassets */, + AE0000000000000000000001 /* LibraryScanner.swift */, + AD0000000000000000000001 /* Hosting.swift */, + AD0000000000000000000002 /* SingerServer.swift */, + AD0000000000000000000003 /* Metadata.swift */, + AE0000000000000000000010 /* DJWorkspace.swift */, + ); + path = KJamsClone; + sourceTree = ""; + }; + B00000000000000000000003 /* KJamsCloneTests */ = { + isa = PBXGroup; + children = ( + A00000000000000000000003 /* KJamsCloneTests.swift */, + ); + path = KJamsCloneTests; + sourceTree = ""; + }; + B00000000000000000000004 /* Products */ = { + isa = PBXGroup; + children = ( + A00000000000000000000001 /* KJMix Pro.app */, + A00000000000000000000002 /* KJMixProTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + D00000000000000000000001 /* KJMix Pro */ = { + isa = PBXNativeTarget; + buildConfigurationList = E00000000000000000000002 /* Build configuration list for PBXNativeTarget "KJMix Pro" */; + buildPhases = ( + C00000000000000000000001 /* Sources */, + AF0000000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "KJMix Pro"; + productName = "KJMix Pro"; + productReference = A00000000000000000000001 /* KJMix Pro.app */; + productType = "com.apple.product-type.application"; + }; + D00000000000000000000002 /* KJMixProTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = E00000000000000000000003 /* Build configuration list for PBXNativeTarget "KJMixProTests" */; + buildPhases = ( + C00000000000000000000002 /* Sources */, + AB0000000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + D00000000000000000000004 /* PBXTargetDependency */, + ); + name = KJMixProTests; + productName = KJMixProTests; + productReference = A00000000000000000000002 /* KJMixProTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F00000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1600; + }; + buildConfigurationList = E00000000000000000000001 /* Build configuration list for PBXProject "KJMix Pro" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = B00000000000000000000001; + productRefGroup = B00000000000000000000004 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + D00000000000000000000001 /* KJMix Pro */, + D00000000000000000000002 /* KJMixProTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AB0000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AB0000000000000000000002 /* Fixtures in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AF0000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AF0000000000000000000002 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + C00000000000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 000000000000000000000065 /* KJMixProApp.swift in Sources */, + 000000000000000000000066 /* Library.swift in Sources */, + 000000000000000000000067 /* CDG.swift in Sources */, + 000000000000000000000068 /* Importer.swift in Sources */, + 000000000000000000000069 /* Player.swift in Sources */, + AE0000000000000000000002 /* LibraryScanner.swift in Sources */, + AC0000000000000000000001 /* Hosting.swift in Sources */, + AC0000000000000000000002 /* SingerServer.swift in Sources */, + AC0000000000000000000003 /* Metadata.swift in Sources */, + AE0000000000000000000011 /* DJWorkspace.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C00000000000000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A00000000000000000000004 /* KJamsCloneTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + D00000000000000000000004 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D00000000000000000000001 /* KJMix Pro */; + targetProxy = D00000000000000000000003 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + F10000000000000000000000 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + ENABLE_TESTABILITY = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + SDKROOT = macosx; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + F10000000000000000000001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + SDKROOT = macosx; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + F20000000000000000000000 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 14; + DEVELOPMENT_TEAM = 39R6U3437D; + ENABLE_APP_SANDBOX = NO; + ENABLE_TESTABILITY = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "KJMix Pro"; + INFOPLIST_KEY_CFBundleName = "KJMix Pro"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Singers on your local network can search the library and manage their own song lists when you enable the Singer Web server."; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 0.6.3; + OTHER_LDFLAGS = "-lsqlite3"; + PRODUCT_BUNDLE_IDENTIFIER = com.kjamsclone.app; + PRODUCT_MODULE_NAME = KJamsClone; + PRODUCT_NAME = "KJMix Pro"; + SDKROOT = macosx; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + F20000000000000000000001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 14; + DEVELOPMENT_TEAM = 39R6U3437D; + ENABLE_APP_SANDBOX = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "KJMix Pro"; + INFOPLIST_KEY_CFBundleName = "KJMix Pro"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Singers on your local network can search the library and manage their own song lists when you enable the Singer Web server."; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 0.6.3; + OTHER_LDFLAGS = "-lsqlite3"; + PRODUCT_BUNDLE_IDENTIFIER = com.kjamsclone.app; + PRODUCT_MODULE_NAME = KJamsClone; + PRODUCT_NAME = "KJMix Pro"; + SDKROOT = macosx; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + F30000000000000000000000 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; + ENABLE_TESTABILITY = YES; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + OTHER_LDFLAGS = "-lsqlite3"; + PRODUCT_BUNDLE_IDENTIFIER = com.kjamsclone.tests; + PRODUCT_NAME = KJMixProTests; + SDKROOT = macosx; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KJMix Pro.app/Contents/MacOS/KJMix Pro"; + }; + name = Debug; + }; + F30000000000000000000001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + OTHER_LDFLAGS = "-lsqlite3"; + PRODUCT_BUNDLE_IDENTIFIER = com.kjamsclone.tests; + PRODUCT_NAME = KJMixProTests; + SDKROOT = macosx; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KJMix Pro.app/Contents/MacOS/KJMix Pro"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + E00000000000000000000001 /* Build configuration list for PBXProject "KJMix Pro" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F10000000000000000000000 /* Debug */, + F10000000000000000000001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E00000000000000000000002 /* Build configuration list for PBXNativeTarget "KJMix Pro" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F20000000000000000000000 /* Debug */, + F20000000000000000000001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E00000000000000000000003 /* Build configuration list for PBXNativeTarget "KJMixProTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F30000000000000000000000 /* Debug */, + F30000000000000000000001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = F00000000000000000000001 /* Project object */; +} diff --git a/KJMix Pro.xcodeproj/xcshareddata/xcschemes/KJMix Pro.xcscheme b/KJMix Pro.xcodeproj/xcshareddata/xcschemes/KJMix Pro.xcscheme new file mode 100644 index 0000000..9a707eb --- /dev/null +++ b/KJMix Pro.xcodeproj/xcshareddata/xcschemes/KJMix Pro.xcscheme @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/KJamsClone/Assets.xcassets/AppIcon.appiconset/Contents.json b/KJamsClone/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..16cea05 --- /dev/null +++ b/KJamsClone/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images": [ + { + "idiom": "mac", + "size": "16x16", + "scale": "1x", + "filename": "icon_16x16@1x.png" + }, + { + "idiom": "mac", + "size": "16x16", + "scale": "2x", + "filename": "icon_16x16@2x.png" + }, + { + "idiom": "mac", + "size": "32x32", + "scale": "1x", + "filename": "icon_32x32@1x.png" + }, + { + "idiom": "mac", + "size": "32x32", + "scale": "2x", + "filename": "icon_32x32@2x.png" + }, + { + "idiom": "mac", + "size": "128x128", + "scale": "1x", + "filename": "icon_128x128@1x.png" + }, + { + "idiom": "mac", + "size": "128x128", + "scale": "2x", + "filename": "icon_128x128@2x.png" + }, + { + "idiom": "mac", + "size": "256x256", + "scale": "1x", + "filename": "icon_256x256@1x.png" + }, + { + "idiom": "mac", + "size": "256x256", + "scale": "2x", + "filename": "icon_256x256@2x.png" + }, + { + "idiom": "mac", + "size": "512x512", + "scale": "1x", + "filename": "icon_512x512@1x.png" + }, + { + "idiom": "mac", + "size": "512x512", + "scale": "2x", + "filename": "icon_512x512@2x.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_128x128@1x.png b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_128x128@1x.png new file mode 100644 index 0000000..c88f066 Binary files /dev/null and b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_128x128@1x.png differ diff --git a/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..ef4a5ba Binary files /dev/null and b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_16x16@1x.png b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_16x16@1x.png new file mode 100644 index 0000000..d0fadd8 Binary files /dev/null and b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_16x16@1x.png differ diff --git a/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..f4af8b4 Binary files /dev/null and b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_256x256@1x.png b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_256x256@1x.png new file mode 100644 index 0000000..ef4a5ba Binary files /dev/null and b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_256x256@1x.png differ diff --git a/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..cd5064b Binary files /dev/null and b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_32x32@1x.png b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_32x32@1x.png new file mode 100644 index 0000000..f4af8b4 Binary files /dev/null and b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_32x32@1x.png differ diff --git a/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..2042f76 Binary files /dev/null and b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_512x512@1x.png b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_512x512@1x.png new file mode 100644 index 0000000..cd5064b Binary files /dev/null and b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_512x512@1x.png differ diff --git a/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..f318f31 Binary files /dev/null and b/KJamsClone/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/KJamsClone/Assets.xcassets/BrandMark.imageset/Contents.json b/KJamsClone/Assets.xcassets/BrandMark.imageset/Contents.json new file mode 100644 index 0000000..bc916aa --- /dev/null +++ b/KJamsClone/Assets.xcassets/BrandMark.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "KJMixBrand.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/KJamsClone/Assets.xcassets/BrandMark.imageset/KJMixBrand.png b/KJamsClone/Assets.xcassets/BrandMark.imageset/KJMixBrand.png new file mode 100644 index 0000000..03199f7 Binary files /dev/null and b/KJamsClone/Assets.xcassets/BrandMark.imageset/KJMixBrand.png differ diff --git a/KJamsClone/Assets.xcassets/Contents.json b/KJamsClone/Assets.xcassets/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/KJamsClone/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/KJamsClone/CDG.swift b/KJamsClone/CDG.swift new file mode 100644 index 0000000..fb1507d --- /dev/null +++ b/KJamsClone/CDG.swift @@ -0,0 +1,69 @@ +import Foundation +import CoreGraphics + +/// CD+G packet decoder. Packet parity is ignored; malformed coordinates are rejected. +struct CDGDecoder: Sendable { + private(set) var pixels = [UInt8](repeating: 0, count: 300 * 216) + private(set) var palette = [UInt32](repeating: 0x000000FF, count: 16) + private(set) var packetIndex = 0 + private(set) var horizontalOffset = 0 + private(set) var verticalOffset = 0 + private var transparent: UInt8? + mutating func render(_ data: Data, at seconds: Double, cancelled: () -> Bool = { false }) { + let target = min(data.count / 24, max(0, Int(max(0, seconds) * 300))) + if target < packetIndex { self = CDGDecoder() } + while packetIndex < target { + if packetIndex % 300 == 0 && cancelled() { return } + apply(Array(data[(packetIndex * 24)..<(packetIndex * 24 + 24)])) + packetIndex += 1 + } + } + mutating func apply(_ packet: [UInt8]) { + guard packet.count == 24, packet[0] & 63 == 9 else { return } + let d = packet[4..<20].map { Int($0 & 63) } + switch packet[1] & 63 { + case 1: + if d[1] == 0 { pixels = .init(repeating: UInt8(d[0] & 15), count: pixels.count) } + case 2: + for y in 0..<216 { for x in 0..<300 where x < 6 || x >= 294 || y < 12 || y >= 204 { pixels[y * 300 + x] = UInt8(d[0] & 15) } } + case 6, 38: + let row = d[2] & 31, col = d[3] & 63 + guard row < 18, col < 50 else { return } + for y in 0..<12 { for x in 0..<6 { + let color = UInt8(d[(y + 4)] & (1 << (5 - x)) == 0 ? d[0] & 15 : d[1] & 15) + let i = (row * 12 + y) * 300 + col * 6 + x + if packet[1] & 63 == 38 { pixels[i] ^= color } else { pixels[i] = color } + } } + case 20, 24: + horizontalOffset = min(d[1] & 7, 5); verticalOffset = min(d[2] & 15, 11) + let hx = (d[1] >> 4) & 3, vy = (d[2] >> 4) & 3 + let dx = hx == 1 ? 6 : hx == 2 ? -6 : 0 + let dy = vy == 1 ? 12 : vy == 2 ? -12 : 0 + let old = pixels + for y in 0..<216 { for x in 0..<300 { + let sx = x - dx, sy = y - dy + if packet[1] & 63 == 24 || (sx >= 0 && sx < 300 && sy >= 0 && sy < 216) { + pixels[y * 300 + x] = old[((sy + 216) % 216) * 300 + (sx + 300) % 300] + } else { pixels[y * 300 + x] = UInt8(d[0] & 15) } + } } + case 28: transparent = UInt8(d[0] & 15) + case 30, 31: + let start = packet[1] & 63 == 30 ? 0 : 8 + for i in 0..<8 { + let v = (d[i * 2] << 6) | d[i * 2 + 1] + palette[start + i] = UInt32(((v >> 8) & 15) * 17) << 24 | UInt32(((v >> 4) & 15) * 17) << 16 | UInt32((v & 15) * 17) << 8 | 255 + } + default: break + } + } + func image() -> CGImage? { + var rgba = [UInt8](); rgba.reserveCapacity(288 * 192 * 4) + for y in 12..<204 { for x in 6..<294 { + let index = pixels[(y + verticalOffset) * 300 + x + horizontalOffset] + let c = palette[Int(index)] + rgba += [UInt8((c >> 24) & 255), UInt8((c >> 16) & 255), UInt8((c >> 8) & 255), index == transparent ? 0 : 255] + } } + guard let provider = CGDataProvider(data: Data(rgba) as CFData) else { return nil } + return CGImage(width: 288, height: 192, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: 288 * 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue), provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent) + } +} diff --git a/KJamsClone/DJWorkspace.swift b/KJamsClone/DJWorkspace.swift new file mode 100644 index 0000000..4cd25e7 --- /dev/null +++ b/KJamsClone/DJWorkspace.swift @@ -0,0 +1,588 @@ +import SwiftUI +import AVFoundation +import AppKit + +struct StudioSettings: Codable, Equatable { + var autoAdvance = true + var repeatTrack = false + var graphicsOffset: Double = 0 + var jogSecondsPerTurn: Double = 2 + var nudgeSeconds: Double = 0.05 + var backgroundPath: String? + var backgroundBookmark: Data? + var backgroundTrim: Double = 0.3 + var backgroundLoop = true + var backgroundFadeIn: Double? = 0 + var backgroundFadeOut: Double? = 0 + var automaticBackground: Bool? = true + var backgroundAutoEnabled: Bool { automaticBackground ?? true } + var serverPort = 8080 + var sanitized: Self { + var value = self + value.graphicsOffset = graphicsOffset.isFinite ? min(5,max(-5,graphicsOffset)) : 0 + value.jogSecondsPerTurn = jogSecondsPerTurn.isFinite ? min(20,max(0.1,jogSecondsPerTurn)) : 2 + value.nudgeSeconds = nudgeSeconds.isFinite ? min(2,max(0.005,nudgeSeconds)) : 0.05 + value.backgroundTrim = backgroundTrim.isFinite ? min(1,max(0,backgroundTrim)) : 0.3 + value.backgroundFadeIn = (backgroundFadeIn ?? 0).isFinite ? min(15,max(0,backgroundFadeIn ?? 0)) : 0 + value.backgroundFadeOut = (backgroundFadeOut ?? 0).isFinite ? min(15,max(0,backgroundFadeOut ?? 0)) : 0 + value.serverPort = min(65535,max(1024,serverPort));return value + } +} + +/// Independent background audio deck. Singer playback always pauses it before the main node starts. +@MainActor final class BackgroundDeck: ObservableObject { + @Published private(set) var title = "No background track" + @Published private(set) var playing = false + @Published private(set) var time: Double = 0 + @Published private(set) var duration: Double = 0 + @Published private(set) var interruptedBySinger = false + @Published private(set) var status = "Choose an audio file" + var trim: Double = 0.3 { didSet { applyVolume() } } + var fadeInSeconds: Double = 0 + var fadeOutSeconds: Double = 0 + private var fadeTask: Task? + private var fadeLevel: Double = 1 + private var singerFadeDeadline: Double? + var repeating = true + private let engine = AVAudioEngine() + private let node = AVAudioPlayerNode() + private let eq = AVAudioUnitEQ(numberOfBands:4) + private(set) var mix = Player.GlobalMix(volume:0.8,low:0,mid:0,high:0,filter:0,enabled:true) + private var file: AVAudioFile? + private var accessURL: URL? + private var scoped = false + private var generation = UUID() + private var anchor: Double = 0 + private var timer: Timer? + var loaded: Bool { file != nil } + var outputGain: Float { engine.mainMixerNode.outputVolume } + init() { engine.attach(node);engine.attach(eq) } + func load(_ url: URL,title: String? = nil) throws { + unload() + scoped = url.startAccessingSecurityScopedResource();accessURL = url + do { + let file = try AVAudioFile(forReading:url) + guard file.length > 0 else { throw LibraryError.message("Background file is empty") } + self.file = file;duration = Double(file.length)/file.processingFormat.sampleRate + self.title = title ?? url.deletingPathExtension().lastPathComponent + engine.connect(node,to:eq,format:file.processingFormat);engine.connect(eq,to:engine.mainMixerNode,format:file.processingFormat) + apply(mix:mix);schedule(at:0);status = "Ready" + } catch { unload();status = "Unavailable: "+error.localizedDescription;throw error } + } + func apply(mix: Player.GlobalMix) { + self.mix = mix;applyVolume() + AudioTone.configure(eq,sampleRate:file?.processingFormat.sampleRate ?? 44100,low:mix.low,mid:mix.mid,high:mix.high,filter:mix.filter,enabled:mix.enabled) + } + private func applyVolume() { engine.mainMixerNode.outputVolume = Float(min(1,max(0,mix.volume*trim*fadeLevel))) } + private func schedule(at seconds: Double) { + guard let file else { return };generation = UUID();let token = generation + node.stop();let rate = file.processingFormat.sampleRate,start = min(file.length,max(0,AVAudioFramePosition(seconds*rate))) + anchor = Double(start)/rate;time = anchor + guard start < file.length else { return } + node.scheduleSegment(file,startingFrame:start,frameCount:AVAudioFrameCount(min(file.length-start,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack) { [weak self] _ in + Task { @MainActor in + guard let self,self.generation == token else { return } + if self.repeating && self.playing { self.schedule(at:0);self.node.play() } + else { self.playing = false;self.time = self.duration;self.status = "Finished · Press Play to restart";self.timer?.invalidate();self.timer = nil } + } + } + } + private func cancelFade() { fadeTask?.cancel();fadeTask = nil;singerFadeDeadline = nil } + private func fade(to target: Double,seconds: Double,completion: (() -> Void)? = nil) { + cancelFade() + guard seconds.isFinite,seconds > 0 else { fadeLevel = target;applyVolume();completion?();return } + let initial = fadeLevel,began = ProcessInfo.processInfo.systemUptime + fadeTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + guard let self else { return } + let fraction = min(1,(ProcessInfo.processInfo.systemUptime-began)/seconds) + self.fadeLevel = initial+(target-initial)*fraction;self.applyVolume() + if fraction >= 1 { self.fadeTask = nil;completion?();return } + do { try await Task.sleep(nanoseconds:16_000_000) } catch { return } + } + } + } + func fadeOut(stop: Bool) { + guard playing || node.isPlaying else { if stop { self.stop() };return } + tick();playing = false;timer?.invalidate();timer = nil + if stop { time = 0 } + status = "Fading out" + fade(to:0,seconds:fadeOutSeconds) { [weak self] in + guard let self else { return } + if stop { self.stop() } else { self.pause() } + } + } + func start() throws { + guard file != nil else { throw LibraryError.message("Choose a background track first") } + if time >= duration { schedule(at:0) } + if !engine.isRunning { engine.prepare();try engine.start() } + cancelFade();if !node.isPlaying { fadeLevel = fadeInSeconds > 0 ? 0 : 1;applyVolume() } + node.play();playing = true;interruptedBySinger = false;status = "Background music playing" + fade(to:1,seconds:fadeInSeconds) + timer?.invalidate();timer = Timer(timeInterval:0.1,repeats:true) { [weak self] _ in Task { @MainActor in self?.tick() } } + if let timer { RunLoop.main.add(timer,forMode:.common) } + } + func seek(_ seconds: Double) { + guard loaded,seconds.isFinite else { return } + let resume = playing,target = min(duration,max(0,seconds)) + cancelFade();fadeLevel = resume ? 1 : 0;applyVolume() + status = resume ? "Background music playing" : (interruptedBySinger ? "Paused for singer" : "Ready at selected position") + schedule(at:target) + if resume,target < duration { node.play() } + else if target >= duration { + playing = false;timer?.invalidate();timer = nil;status = "At end · Press Play to restart" + } + } + private func tick() { + if node.isPlaying,let file,let render = node.lastRenderTime,let played = node.playerTime(forNodeTime:render) { time = min(duration,anchor+Double(played.sampleTime)/file.processingFormat.sampleRate) } + } + /// Return the remaining audible fade time; the singer node waits before starting. + func prepareForSinger() -> Double { + if let deadline = singerFadeDeadline { return max(0,deadline-ProcessInfo.processInfo.systemUptime) } + guard playing || node.isPlaying else { return 0 } + let seconds = fadeOutSeconds.isFinite ? min(15,max(0,fadeOutSeconds)) : 0 + guard seconds > 0 else { pauseForSinger();return 0 } + tick();playing = false;interruptedBySinger = true;timer?.invalidate();timer = nil + status = "Fading out for singer" + fade(to:0,seconds:seconds) { [weak self] in self?.pauseForSinger() } + singerFadeDeadline = ProcessInfo.processInfo.systemUptime+seconds + return seconds + } + func pauseForSinger() { guard playing || node.isPlaying else { return };cancelFade();tick();node.pause();fadeLevel = 0;applyVolume();playing = false;interruptedBySinger = true;status = "Paused for singer";timer?.invalidate();timer = nil } + func pause() { guard playing || node.isPlaying else { return };cancelFade();tick();node.pause();fadeLevel = 0;applyVolume();playing = false;interruptedBySinger = false;status = "Paused · Press Play to resume";timer?.invalidate();timer = nil } + func stop() { cancelFade();fadeLevel = 0;applyVolume();node.stop();playing = false;interruptedBySinger = false;timer?.invalidate();timer = nil;schedule(at:0);status = file == nil ? "Choose an audio file" : "Stopped · Ready" } + func unload() { + cancelFade();fadeLevel = 0;generation = UUID();node.stop();engine.stop();timer?.invalidate();timer = nil + engine.disconnectNodeOutput(node);engine.disconnectNodeOutput(eq);file = nil + if scoped,let accessURL { accessURL.stopAccessingSecurityScopedResource() };scoped = false;accessURL = nil + title = "No background track";playing = false;interruptedBySinger = false;time = 0;duration = 0;status = "Choose an audio file" + } + deinit { fadeTask?.cancel();timer?.invalidate();if scoped,let accessURL { accessURL.stopAccessingSecurityScopedResource() } } +} + +@MainActor extension AppModel { + func applyStudioSettings(save: Bool,previous: StudioSettings? = nil) { + let value = studio.sanitized + if previous == nil || previous?.autoAdvance != value.autoAdvance { autoAdvance = value.autoAdvance } + if previous == nil || previous?.repeatTrack != value.repeatTrack { player.repeating = value.repeatTrack } + if previous == nil || previous?.graphicsOffset != value.graphicsOffset { player.graphicsOffset = value.graphicsOffset } + player.jogSecondsPerTurn = value.jogSecondsPerTurn;player.nudgeStep = value.nudgeSeconds + backgroundDeck.fadeInSeconds = value.backgroundFadeIn ?? 0;backgroundDeck.fadeOutSeconds = value.backgroundFadeOut ?? 0 + backgroundDeck.trim = value.backgroundTrim;backgroundDeck.repeating = value.backgroundLoop;backgroundDeck.apply(mix:player.globalMix) + if save,let data = try? JSONEncoder().encode(value) { do { try data.write(to:root.appendingPathComponent("StudioSettings.json"),options:.atomic) } catch { self.error = error.localizedDescription } } + if previous == nil || previous?.backgroundPath != value.backgroundPath || previous?.backgroundBookmark != value.backgroundBookmark { restoreBackground() } + if previous != nil { scheduleBackgroundHandoff() } + } + func restoreBackground() { + guard let path = studio.backgroundPath else { backgroundDeck.unload();return } + var url = URL(fileURLWithPath:path) + if let bookmark = studio.backgroundBookmark { var stale = false;if let resolved = try? URL(resolvingBookmarkData:bookmark,options:.withSecurityScope,relativeTo:nil,bookmarkDataIsStale:&stale) { url = resolved } } + do { try backgroundDeck.load(url) } catch { /* The deck shows its unavailable state without interrupting navigation. */ } + } + func chooseBackground() { + let panel = NSOpenPanel();panel.title = "Choose Background Music";panel.allowedContentTypes = [.audio];panel.allowsMultipleSelection = false;panel.canChooseDirectories = false + guard panel.runModal() == .OK,let url = panel.url else { return } + var next = studio;next.backgroundPath = url.path + next.backgroundBookmark = try? url.bookmarkData(options:.withSecurityScope,includingResourceValuesForKeys:nil,relativeTo:nil);studio = next + } + func scheduleBackgroundHandoff() { + guard backgroundAutomationReady else { return } + backgroundHandoff?.cancel() + backgroundHandoff = Task { @MainActor [weak self] in + do { try await Task.sleep(nanoseconds:250_000_000) } catch { return } + guard let self,self.studio.backgroundAutoEnabled,self.backgroundDeck.loaded, + !self.backgroundDeck.playing,(!self.player.playing || self.player.isShowScreen) else { return } + self.perform { try self.backgroundDeck.start() } + } + } + func pauseBackground() { studio.automaticBackground = false;backgroundHandoff?.cancel();backgroundDeck.fadeOut(stop:false) } + func stopBackground() { studio.automaticBackground = false;backgroundHandoff?.cancel();backgroundDeck.fadeOut(stop:true) } + func playBackground() { + guard !player.playing || player.isShowScreen else { error = "Stop or pause the singer deck before starting background music";return } + perform { try backgroundDeck.start() } + } + func placeSinger(_ singer: Int64,before: Int64?) { perform { guard let currentVenue,let library else { return };try library.placeRotation(singer:singer,venue:currentVenue,before:before);try refresh() } } +} + +struct BackgroundDeckStrip: View { + @ObservedObject var model: AppModel + @ObservedObject var deck: BackgroundDeck + private func stamp(_ seconds: Double) -> String { let value = Int(max(0,seconds));return String(format:"%d:%02d",value/60,value%60) } + var body: some View { + HStack(spacing:12) { + Image(systemName:"music.note").font(.title3).foregroundStyle(deck.playing ? .green : .secondary) + VStack(alignment:.leading,spacing:3) { + Text(deck.loaded ? deck.title : "Background Music").font(.system(size:12,weight:.semibold)).lineLimit(1) + Text(deck.status).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1) + if deck.loaded { + HStack(spacing:6) { + Text(stamp(deck.time)).monospacedDigit() + Slider(value:Binding(get:{deck.time},set:{deck.seek($0)}),in:0...max(0.001,deck.duration)).controlSize(.small).accessibilityLabel("Background track position").help("Drag to find the background track’s starting point; paused music stays paused") + Text(stamp(deck.duration)).monospacedDigit() + }.font(.system(size:10)).foregroundStyle(.secondary) + } + }.frame(maxWidth:.infinity,alignment:.leading) + Toggle("Auto",isOn:Binding(get:{model.studio.backgroundAutoEnabled},set:{model.studio.automaticBackground = $0})).toggleStyle(.checkbox).help("Start background music between singers; pause it before singer audio starts") + Button("Choose…") { model.chooseBackground() }.controlSize(.small) + Button { if deck.playing { model.pauseBackground() } else { model.playBackground() } } label: { Image(systemName:deck.playing ? "pause.fill" : "play.fill").frame(width:23) }.disabled(!deck.loaded).help(deck.playing ? "Pause background music" : "DJ: start or resume background music") + Button { model.stopBackground() } label: { Image(systemName:"stop.fill") }.disabled(!deck.loaded).help("Stop background and return to start") + Slider(value:$model.studio.backgroundTrim,in:0...1).frame(width:85).controlSize(.small).help("Background level relative to global master").accessibilityLabel("Background level") + Text("\(Int(model.studio.backgroundTrim*100))%").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width:30) + }.padding(.horizontal,12).padding(.vertical,8).background(Color.primary.opacity(0.035),in:RoundedRectangle(cornerRadius:9)) + } +} + +struct DJDeckControls: View { + @ObservedObject var player: Player + @State private var angle: Double? + @State private var resume = false + @State private var song: Int64? + private func stamp(_ value: Double) -> String { String(format:"%d:%05.2f",Int(max(0,value))/60,max(0,value).truncatingRemainder(dividingBy:60)) } + var body: some View { + HStack(spacing:7) { + ZStack { + Circle().fill(Color.white.opacity(0.035));Circle().strokeBorder(Color.white.opacity(0.16),lineWidth:1) + Circle().trim(from:0.05,to:0.95).stroke(Color.accentColor.opacity(0.65),style:StrokeStyle(lineWidth:2,lineCap:.round)).padding(5) + Text("JOG").font(.system(size:8,weight:.bold)).foregroundStyle(.secondary) + }.frame(width:43,height:43).contentShape(Circle()).gesture(DragGesture(minimumDistance:0).onChanged { event in + guard player.canJog else { return };let next = atan2(event.location.y-21.5,event.location.x-21.5) + if let angle { var delta = next-angle;if delta > .pi { delta -= 2 * .pi };if delta < -.pi { delta += 2 * .pi };player.jog(delta/(2 * .pi)*player.jogSecondsPerTurn) } + else { resume = player.playing;song = player.currentSongID;if player.playing { player.toggle() } } + angle = next + }.onEnded { _ in angle = nil;if resume,player.currentSongID == song,!player.playing { player.toggle() };resume = false }) + .help("Drag around the jog wheel for precise positioning. Playback resumes on release.").accessibilityLabel("Jog wheel").accessibilityAdjustableAction { player.jog($0 == .increment ? player.nudgeStep : -player.nudgeStep) } + Button { player.toggle() } label: { Image(systemName:player.playing ? "pause.fill" : "play.fill") }.help("Play / pause main deck") + Button { player.jog(-player.nudgeStep) } label: { Image(systemName:"backward.frame") }.help("Nudge backward") + Button { player.jog(player.nudgeStep) } label: { Image(systemName:"forward.frame") }.help("Nudge forward") + Divider().frame(height:24) + VStack(spacing:2) { HStack(spacing:4) { Button("Cue") { player.returnToCue() }.help("Pause and return to cue");Button { player.setCue() } label: { Image(systemName:"flag.fill") }.help("Set cue at current position") };Text(stamp(player.cuePosition)).font(.system(size:8,design:.monospaced)).foregroundStyle(.secondary) } + HStack(spacing:3) { ForEach(0..<4) { index in Button("\(index+1)") { player.hotCue(index) }.foregroundStyle(player.hotCues[index] == nil ? Color.secondary : Color.accentColor).help(player.hotCues[index] == nil ? "Set hot cue \(index+1)" : "Jump to hot cue \(index+1)") + .contextMenu { Button("Set Here") { player.hotCue(index,clear:true);player.hotCue(index) };Button("Clear") { player.hotCue(index,clear:true) } } + } } + Spacer(minLength:0) + Button("In") { player.markLoopIn() }.foregroundStyle(player.loopIn == nil ? Color.primary : Color.accentColor).help("Set loop start") + Button("Out") { player.markLoopOut() }.disabled(player.loopIn == nil).help("Set loop end and enable loop") + Button { player.loopEnabled.toggle() } label: { Image(systemName:"repeat") }.disabled(player.loopOut == nil).foregroundStyle(player.loopEnabled ? Color.accentColor : Color.secondary).help("Toggle A/B loop").contextMenu { Button("Clear Loop") { player.clearLoop() } } + Button { player.stopDeck() } label: { Image(systemName:"stop.fill") }.help("Stop immediately, keep track and waveform loaded") + Button { player.fadeOutAndStop() } label: { Image(systemName:"speaker.slash") }.help("Fade out and unload") + }.buttonStyle(.borderless).controlSize(.small).font(.system(size:10,weight:.medium)).disabled(!player.canJog) + } +} + +struct StudioSettingsPane: View { + @ObservedObject var model: AppModel + @State private var port = "" + var body: some View { + ScrollView { + VStack(alignment:.leading,spacing:22) { + HStack { VStack(alignment:.leading,spacing:4) { Text("Studio Settings").font(.title2.bold());Text("Changes save automatically on this Mac.").foregroundStyle(.secondary) };Spacer() } + GroupBox("Playback & Jog") { + VStack(alignment:.leading,spacing:12) { + Toggle("Automatically play the next queued singer",isOn:$model.studio.autoAdvance) + Toggle("Repeat on startup",isOn:$model.studio.repeatTrack) + HStack { Text("Jog sensitivity");Slider(value:$model.studio.jogSecondsPerTurn,in:0.25...10,step:0.25);Text(String(format:"%.2fs / turn",model.studio.jogSecondsPerTurn)).monospacedDigit().frame(width:100) } + Picker("Nudge step",selection:$model.studio.nudgeSeconds) { Text("10 ms").tag(0.01);Text("50 ms").tag(0.05);Text("100 ms").tag(0.1);Text("500 ms").tag(0.5) }.frame(maxWidth:320) + HStack { Text("Video / CDG offset");Slider(value:$model.studio.graphicsOffset,in:-2...2,step:0.05);Text(String(format:"%+.2fs",model.studio.graphicsOffset)).monospacedDigit().frame(width:65) } + Text("Option-scroll over the waveform to jog. Cue, hot cues and loops belong to the loaded deck and reset on track change.").font(.caption).foregroundStyle(.secondary) + }.padding(10) + } + GroupBox("Hosting · \(model.currentVenueName)") { + VStack(alignment:.leading,spacing:12) { + Toggle("Fade song endings",isOn:Binding(get:{model.hostSettings.autoFade},set:{model.hostSettings.autoFade = $0;model.saveHostSettings()})) + Stepper("Fade duration: \(Int(model.hostSettings.fadeSeconds)) seconds",value:Binding(get:{model.hostSettings.fadeSeconds},set:{model.hostSettings.fadeSeconds = $0;model.saveHostSettings()}),in:1...15,step:1) + Picker("Between singers",selection:Binding(get:{model.hostSettings.intermission},set:{model.hostSettings.intermission = $0;model.saveHostSettings()})) { Text("No automatic slide").tag(nil as Int64?);ForEach(model.showScreens) { Text($0.title).tag(Optional($0.id)) } } + }.padding(10) + }.disabled(model.currentVenue == nil) + GroupBox("Background Music") { + VStack(alignment:.leading,spacing:12) { + HStack { Text(model.studio.backgroundPath.map{URL(fileURLWithPath:$0).lastPathComponent} ?? "No track selected").lineLimit(1);Spacer();Button("Choose Track…") { model.chooseBackground() };Button("Clear") { model.studio.backgroundPath = nil;model.studio.backgroundBookmark = nil }.disabled(model.studio.backgroundPath == nil) } + Toggle("Automatically play background music between singers",isOn:Binding(get:{model.studio.backgroundAutoEnabled},set:{model.studio.automaticBackground = $0})) + Toggle("Loop background track",isOn:$model.studio.backgroundLoop) + HStack { Text("Fade in");Slider(value:Binding(get:{model.studio.backgroundFadeIn ?? 0},set:{model.studio.backgroundFadeIn = $0}),in:0...15,step:0.25);Text(String(format:"%.2f s",model.studio.backgroundFadeIn ?? 0)).monospacedDigit().frame(width:65) } + HStack { Text("Fade out");Slider(value:Binding(get:{model.studio.backgroundFadeOut ?? 0},set:{model.studio.backgroundFadeOut = $0}),in:0...15,step:0.25);Text(String(format:"%.2f s",model.studio.backgroundFadeOut ?? 0)).monospacedDigit().frame(width:65) } + Text("Play uses Fade in; background Pause and Stop use Fade out. Zero disables a fade. Singer audio waits for the background fade-out to finish.").font(.caption).foregroundStyle(.secondary) + HStack { Text("Background level");Slider(value:$model.studio.backgroundTrim,in:0...1);Text("\(Int(model.studio.backgroundTrim*100))%").monospacedDigit() } + Text("Auto resumes background music after the singer stops, pauses, or finishes. Starting a singer fades it out first. Manual background Pause or Stop switches Auto off. Restoring a saved track at launch does not autoplay. Global EQ and master apply to both decks.").font(.caption).foregroundStyle(.secondary) + }.padding(10) + } + GlobalMixerSettings(player:model.player) + GroupBox("Singer Web & Library") { + VStack(alignment:.leading,spacing:12) { + HStack { Text("Default server port");TextField("8080",text:$port).frame(width:90);Button("Apply Port") { if let value = Int(port),(1024...65535).contains(value) { model.studio.serverPort = value } else { model.error = "Use a port from 1024 to 65535" } } } + Text("Used next time you start Singer Web. The server remains off until started explicitly.").font(.caption).foregroundStyle(.secondary) + HStack { Button("Add Drive / Folder…") { model.chooseMediaLocation() };Button("Show Library Data") { NSWorkspace.shared.open(model.root) } } + Text("Audio output follows the Mac’s current sound output. Configure hardware output in macOS Sound settings.").font(.caption).foregroundStyle(.secondary) + }.padding(10) + } + }.padding(22) + }.background(Color(nsColor:.windowBackgroundColor)).onAppear { port = String(model.studio.serverPort) } + } +} +private struct GlobalMixerSettings: View { + @ObservedObject var player: Player + var body: some View { + GroupBox("Global Master & EQ") { + VStack(alignment:.leading,spacing:10) { + Toggle("Enable EQ",isOn:$player.toneEnabled) + HStack { Text("Master").frame(width:45,alignment:.leading);Slider(value:$player.volume,in:0...1);Text("\(Int(player.volume*100))%").monospacedDigit().frame(width:45) } + HStack { Text("Low").frame(width:45,alignment:.leading);Slider(value:$player.lowGain,in:-12...12,step:0.5);Text(String(format:"%+.1fdB",player.lowGain)).monospacedDigit().frame(width:60) } + HStack { Text("Mid").frame(width:45,alignment:.leading);Slider(value:$player.midGain,in:-12...12,step:0.5);Text(String(format:"%+.1fdB",player.midGain)).monospacedDigit().frame(width:60) } + HStack { Text("High").frame(width:45,alignment:.leading);Slider(value:$player.highGain,in:-12...12,step:0.5);Text(String(format:"%+.1fdB",player.highGain)).monospacedDigit().frame(width:60) } + }.padding(10) + } + } +} + +struct WaveformJogWheel: NSViewRepresentable { + let player: Player + func makeNSView(context: Context) -> WheelView { let view = WheelView();view.player = player;view.install();return view } + func updateNSView(_ view: WheelView,context: Context) { view.player = player } + static func dismantleNSView(_ view: WheelView,coordinator: ()) { view.remove() } + final class WheelView: NSView { + weak var player: Player? + private var monitor: Any? + override func hitTest(_ point: NSPoint) -> NSView? { nil } + func install() { + monitor = NSEvent.addLocalMonitorForEvents(matching:.scrollWheel) { [weak self] event in + guard let self,event.window === self.window,self.window != nil,event.modifierFlags.contains(.option),self.bounds.contains(self.convert(event.locationInWindow,from:nil)),let player = self.player,player.canJog else { return event } + player.jog(-Double(event.scrollingDeltaY)/120*player.jogSecondsPerTurn);return nil + } + } + func remove() { if let monitor { NSEvent.removeMonitor(monitor) };monitor = nil } + deinit { if let monitor { NSEvent.removeMonitor(monitor) } } + } +} + +struct RotationDrag: Codable { + var session: String;var venue: Int64;var singer: Int64 + var text: String { "KJMix.rotation.v1:"+((try? JSONEncoder().encode(self)) ?? Data()).base64EncodedString() } + static func parse(_ text: String) -> Self? { guard text.hasPrefix("KJMix.rotation.v1:"),let data = Data(base64Encoded:String(text.dropFirst("KJMix.rotation.v1:".count))) else { return nil };return try? JSONDecoder().decode(Self.self,from:data) } +} +@MainActor extension AppModel { + func rotationReference(_ singer: Int64) -> RotationDrag? { currentVenue.map{.init(session:rotationSession,venue:$0,singer:singer)} } + func applyRotationDrop(_ reference: RotationDrag,before: Int64?) throws { + guard reference.session == rotationSession,reference.venue == currentVenue,rotationSingers.contains(where:{$0.id == reference.singer}),let library else { throw LibraryError.message("Drag a singer from the current venue’s rotation") } + if let before,!rotationSingers.contains(where:{$0.id == before}) { try library.markHere(singer:before,venue:reference.venue,here:false) } + try library.placeRotation(singer:reference.singer,venue:reference.venue,before:before);try refresh();synchronizeManualSingerOrder();resetRound() + } + func synchronizeManualSingerOrder() { + guard !hostSettings.smart,let venue = currentVenue,let library else { return } + perform { try library.orderQueueByRoster(venue:venue);try refresh() } + } + func startRotation() { + guard currentVenue != nil else { error = "Choose a venue, then add singers and songs to Tonight.";return } + if rotationDeckSongID != nil,rotationDeckSongID == player.currentSongID,rotationDeckVenue == currentVenue,player.canJog,player.time < player.duration { + studio.autoAdvance = true + if !player.playing { player.toggle() };rotationPlaybackActive = true + return + } + guard !rotationPlan.isEmpty else { + error = queue.isEmpty ? "Add a song to a singer’s Tonight list using the song browser below." : "Mark a singer with pending songs Here, or select Manual Queue." + return + } + studio.autoAdvance = true;playQueueNext() + } + func pauseRotation() { if rotationPlaybackActive,player.playing { player.toggle() } } + func stopRotation() { studio.autoAdvance = false;rotationPlaybackActive = false;player.stopDeck() } + func reorderRotationSinger(_ singer: Int64,by offset: Int) { + perform { + guard let library,let venue = currentVenue else { return } + for person in venueSingers where !rotationSingers.contains(where:{$0.id == person.id}) { try library.markHere(singer:person.id,venue:venue,here:false) } + try library.moveRotation(singer:singer,venue:venue,by:offset);try refresh();synchronizeManualSingerOrder();resetRound() + } + } + func applyRosterDropText(_ text: String,target: Int64?) async throws { + if let reference = RotationDrag.parse(text) { try applyRotationDrop(reference,before:target) } + else if let target,let venue = currentVenue { + let reference = try await SongDragReference.load(NSItemProvider(object:text as NSString)) + try placeDroppedSong(reference,singer:target,venue:venue,before:nil) + } else { throw LibraryError.message("Drop a singer here to reorder, or a library song onto a singer.") } + } + func acceptRosterDrop(_ providers: [NSItemProvider],target: Int64?) -> Bool { + let items = providers.filter{$0.canLoadObject(ofClass:NSString.self)} + guard !items.isEmpty else { return false } + for item in items { + _ = item.loadObject(ofClass:NSString.self) { [weak self] value,_ in + guard let text = value as? String else { return } + Task { @MainActor in + do { try await self?.applyRosterDropText(text,target:target) } + catch { self?.error = error.localizedDescription } + } + } + } + return true + } + func playPerformance(_ entry: Int64) { + perform { guard let row = queue.first(where:{$0.id == entry}),let song = songs.first(where:{$0.id == row.songID}),let library else { return };selectedSinger = row.singerID;playingIntermission = false;try play(song,singer:row.singerID);try library.removeQueue(row.id);try refresh();rotationPlaybackActive = true;rotationDeckSongID = song.id;rotationDeckVenue = currentVenue } + } +} +struct RotationDashboard: View { + @ObservedObject var model: AppModel + @State private var addingSongs = false + private var roster: [PersonPlace] { + let ids = model.rotationSingers.map(\.id) + return ids.compactMap{id in model.venueSingers.first{$0.id == id}}+model.venueSingers.filter{!ids.contains($0.id)} + } + private var selected: PersonPlace? { model.venueSingers.first{$0.id == model.selectedSinger} } + var body: some View { + VStack(spacing:14) { + HStack(spacing:12) { + Picker("Rotation mode",selection:$model.hostSettings.smart) { Text("Manual Queue").tag(false);Text("Round Robin").tag(true) }.pickerStyle(.segmented).labelsHidden().frame(width:255) + Toggle("Auto advance",isOn:$model.studio.autoAdvance).toggleStyle(.checkbox).font(.caption) + Spacer();Menu("Add Singer") { Button("New Singer…") { model.beginSinger() };ForEach(model.singers.filter{person in !model.venueSingers.contains{$0.id == person.id}}) { person in Button(person.name) { model.membership(singer:person.id,included:true);model.markHere(person.id,true);model.selectedSinger = person.id;addingSongs = true } } };Button("Add Songs…") { if model.selectedSinger == nil { model.selectedSinger = roster.first?.id };addingSongs = true }.disabled(roster.isEmpty) + } + RotationTransport(model:model,player:model.player) + HStack { + Text("Up next:").foregroundStyle(.secondary) + Text(model.rotationPlan.first.map { $0.singer+" · "+$0.title } ?? "Add songs below to begin").lineLimit(1) + Spacer();Text("\(model.rotationSingers.filter(\.here).count) singers Here").foregroundStyle(.secondary) + }.font(.caption) + HSplitView { + VStack(alignment:.leading,spacing:8) { + HStack { Text("Singers").font(.headline);Spacer();Button { model.beginSinger() } label: { Image(systemName:"person.badge.plus") }.buttonStyle(.borderless).help("Add a singer") } + ScrollView { + LazyVStack(spacing:5) { + ForEach(Array(roster.enumerated()),id:\.element.id) { index,singer in rosterRow(singer,index:index) } + if roster.isEmpty { Text("Choose a venue and add singers to begin.").font(.callout).foregroundStyle(.secondary).padding(16) } + Text("Drag a singer here to place last").font(.caption2).foregroundStyle(.secondary).frame(maxWidth:.infinity).padding(10) + .onDrop(of:["public.text"],isTargeted:nil) { model.acceptRosterDrop($0,target:nil) } + } + } + Text(model.hostSettings.smart ? "One turn each · Empty singers keep their place" : "Manual queue order decides who sings next").font(.caption2).foregroundStyle(.secondary) + }.padding(12).frame(minWidth:210,idealWidth:250,maxWidth:300).background(Color.primary.opacity(0.025),in:RoundedRectangle(cornerRadius:10)) + VStack(alignment:.leading,spacing:8) { + HStack { Picker("Rotation panel",selection:$addingSongs) { Text("Upcoming").tag(false);Text("Add Songs").tag(true) }.pickerStyle(.segmented).labelsHidden().frame(width:220);Spacer();Text("\(model.rotationPlan.count) pending").font(.caption).foregroundStyle(.secondary) + Menu { Button("Reset Round") { model.resetRound() };Button("Mark All Here") { model.markAllSingersHere() } } label: { Image(systemName:"ellipsis.circle") }.menuStyle(.borderlessButton).frame(width:18) + } + if addingSongs { + RotationSongBrowser(model:model) + } else { + ScrollView { + LazyVStack(spacing:0) { + ForEach(Array(model.rotationPlan.enumerated()),id:\.element.id) { index,row in + HStack(spacing:10) { + Text("\(index+1)").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width:20) + VStack(alignment:.leading,spacing:4) { Text(row.singer).font(.system(size:12,weight:.semibold));Text(row.title).font(.caption).foregroundStyle(.secondary).lineLimit(1) };Spacer() + if !model.hostSettings.smart { + Button { model.moveQueue(row.id,by:-1) } label: { Image(systemName:"chevron.up") }.disabled(index == 0) + Button { model.moveQueue(row.id,by:1) } label: { Image(systemName:"chevron.down") }.disabled(index == model.rotationPlan.count-1) + } + Button { model.playPerformance(row.id) } label: { Image(systemName:"play.fill") }.help("Play this performance now") + Menu { Button("Remove Performance",role:.destructive) { model.removeQueue(row.id) };Menu("Assign Singer") { Button("No Singer / Filler") { model.assignQueue(row.id,singer:nil) };ForEach(model.venueSingers) { singer in Button(singer.name) { model.assignQueue(row.id,singer:singer.id) } } } } label: { Image(systemName:"ellipsis") }.menuStyle(.borderlessButton).frame(width:15) + }.buttonStyle(.borderless).padding(.vertical,10).padding(.horizontal,8).background(index == 0 ? Color.accentColor.opacity(0.06) : .clear) + Divider().opacity(0.4) + } + if model.rotationPlan.isEmpty { VStack(spacing:10) { Image(systemName:"text.badge.plus").font(.title);Text("No upcoming performances").font(.headline);Text("Select a singer, add songs to Tonight, and mark them Here for round robin.").font(.caption).multilineTextAlignment(.center) }.foregroundStyle(.secondary).frame(maxWidth:.infinity).padding(22) } + } + } + } + }.padding(12).frame(minWidth:360).background(Color.primary.opacity(0.025),in:RoundedRectangle(cornerRadius:10)) + } + }.padding(.horizontal,18).padding(.bottom,16).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark) + .onChange(of:model.hostSettings.smart) { _ in model.saveHostSettings() } + .onChange(of:model.rotationPlaybackActive) { active in if active { addingSongs = false } } + } + private func rosterRow(_ singer: PersonPlace,index: Int) -> some View { + HStack(spacing:7) { + Toggle("Here",isOn:Binding(get:{model.rotationSingers.contains{$0.id == singer.id && $0.here}},set:{model.markHere(singer.id,$0)})).labelsHidden().toggleStyle(.checkbox).help("Singer is here tonight") + Button { model.selectedSinger = singer.id } label: { VStack(alignment:.leading,spacing:3) { Text(singer.name).font(.system(size:12,weight:.medium)).lineLimit(1);Text("\(model.queue.filter{$0.singerID == singer.id}.count) pending").font(.caption2).foregroundStyle(.secondary) }.frame(maxWidth:.infinity,alignment:.leading).contentShape(Rectangle()) }.buttonStyle(.plain) + Button { model.selectedSinger = singer.id;addingSongs = true } label: { Label("Songs",systemImage:"plus.circle").font(.caption).fixedSize() }.buttonStyle(.borderless).help("Add songs for \(singer.name)") + VStack(spacing:3) { + Button { model.reorderRotationSinger(singer.id,by:-1) } label: { Image(systemName:"chevron.up") }.disabled(index == 0).accessibilityIdentifier("rotation.up.\(singer.id)") + Button { model.reorderRotationSinger(singer.id,by:1) } label: { Image(systemName:"chevron.down") }.disabled(index == roster.count-1).accessibilityIdentifier("rotation.down.\(singer.id)") + }.font(.system(size:10)).buttonStyle(.borderless) + Image(systemName:"line.3.horizontal").font(.caption).foregroundStyle(.secondary).frame(width:28,height:36).contentShape(Rectangle()) + .onDrag { + if !model.rotationSingers.contains(where:{$0.id == singer.id}) { model.markHere(singer.id,false) } + return NSItemProvider(object:(model.rotationReference(singer.id)?.text ?? "") as NSString) + }.help("Drag to reorder singers") + }.padding(9).background(model.selectedSinger == singer.id ? Color.accentColor.opacity(0.14) : Color.primary.opacity(0.025),in:RoundedRectangle(cornerRadius:7)) + .onDrop(of:["public.text"],isTargeted:nil) { model.acceptRosterDrop($0,target:singer.id) } + .contextMenu { Button("Manage Tonight…") { model.selectedSinger = singer.id;addingSongs = true };Button("Earlier") { model.reorderRotationSinger(singer.id,by:-1) };Button("Later") { model.reorderRotationSinger(singer.id,by:1) } } + } +} + +private struct RotationNowCard: View { + @ObservedObject var player: Player + let singer: String? + var body: some View { + VStack(alignment:.leading,spacing:5) { + HStack { Circle().fill(player.playing ? Color.green : Color.secondary).frame(width:5,height:5);Text(player.playing ? "ON STAGE" : "DECK READY").font(.system(size:9,weight:.bold)).foregroundStyle(.secondary);Spacer();Button { player.stopDeck() } label: { Image(systemName:"stop.fill") }.buttonStyle(.borderless).disabled(!player.canJog) } + Text(player.currentSongID == nil ? "No singer playing" : singer ?? "Main Deck").font(.headline).lineLimit(1) + Text(player.title).font(.caption).foregroundStyle(.secondary).lineLimit(1) + }.frame(maxWidth:.infinity,alignment:.leading).padding(12).background(Color.primary.opacity(0.035),in:RoundedRectangle(cornerRadius:9)) + } +} +private struct RotationTonightSheet: View { + @ObservedObject var model: AppModel + let singer: PersonPlace + @State private var query = "" + @Environment(\.dismiss) private var dismiss + var body: some View { + VStack(alignment:.leading,spacing:14) { + Text("Tonight · "+singer.name).font(.title2.bold()) + TextField("Search the library",text:$query).textFieldStyle(.roundedBorder) + List(model.songs.filter{!$0.audio.hasPrefix("showscreen:") && SongSearch.matches($0,query:query)}) { song in + HStack { + Toggle(isOn:Binding(get:{model.tonightSongIDs[singer.id]?.contains(song.id) == true},set:{if $0 { model.addToTonight(song.id,singer:singer.id) } else { model.removeFromTonight(song.id,singer:singer.id) }})) { VStack(alignment:.leading,spacing:3) { Text(song.title);Text(song.artist).font(.caption).foregroundStyle(.secondary) } }.toggleStyle(.checkbox) + Spacer() + if model.queue.contains(where:{$0.singerID == singer.id && $0.songID == song.id}) { Text("Queued").font(.caption).foregroundStyle(.green) } + else if model.tonightSongIDs[singer.id]?.contains(song.id) == true { Button("Queue Again") { model.addToTonight(song.id,singer:singer.id) }.controlSize(.small) } + } + }.frame(height:330) + HStack { Text("Adding a song marks this singer Here and puts it in the rotation.").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) } + }.padding(24).frame(width:570).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark) + } +} + +@MainActor extension AppModel { + func markAllSingersHere() { + perform { + guard let library,let currentVenue else { return };try library.execute("BEGIN IMMEDIATE") + do { for singer in venueSingers { try library.markHere(singer:singer.id,venue:currentVenue,here:true) };try library.execute("COMMIT") } catch { try? library.execute("ROLLBACK");throw error };try refresh() + } + } +} + +struct RotationTransport: View { + @ObservedObject var model: AppModel + @ObservedObject var player: Player + var body: some View { + HStack(spacing:12) { + Button { model.startRotation() } label: { Label(model.rotationPlaybackActive && !player.playing ? "Resume Rotation" : "Start Rotation",systemImage:"play.fill") }.buttonStyle(.borderedProminent).disabled(model.rotationPlaybackActive && player.playing).accessibilityIdentifier("rotation.start") + Button { model.pauseRotation() } label: { Label("Pause",systemImage:"pause.fill") }.disabled(!model.rotationPlaybackActive || !player.playing) + Button { model.stopRotation() } label: { Label("Stop",systemImage:"stop.fill") }.disabled(!model.rotationPlaybackActive) + Button { model.studio.autoAdvance = true;model.playQueueNext() } label: { Label("Next Singer",systemImage:"forward.end.fill") }.disabled(model.rotationPlan.isEmpty) + Spacer() + Text(player.waitingForBackground ? "Fading background…" : model.rotationPlaybackActive ? (player.playing ? "Rotation playing" : "Rotation paused") : "Ready to start").font(.caption).foregroundStyle(.secondary) + }.controlSize(.small) + } +} +struct RotationSongBrowser: View { + @ObservedObject var model: AppModel + @State private var query = "" + var body: some View { + VStack(alignment:.leading,spacing:7) { + HStack { + Text("Add songs to Tonight").font(.system(size:12,weight:.semibold));Spacer() + Picker("Singer",selection:$model.selectedSinger) { Text("Choose singer…").tag(nil as Int64?);ForEach(model.venueSingers) { Text($0.name).tag(Optional($0.id)) } }.labelsHidden().frame(width:150) + } + TextField("Search title, artist or album",text:$query).textFieldStyle(.roundedBorder) + ScrollView { + LazyVStack(spacing:3) { + ForEach(model.songs.filter{!$0.audio.hasPrefix("showscreen:") && SongSearch.matches($0,query:query)}) { song in + HStack { + VStack(alignment:.leading,spacing:2) { Text(song.title).font(.system(size:12,weight:.medium)).lineLimit(1);Text(song.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) } + Spacer() + let queued = model.queue.contains{$0.songID == song.id && $0.singerID == model.selectedSinger} + Button(queued ? "Queued" : "Add to Tonight") { model.addToTonight(song.id) }.disabled(model.selectedSinger == nil || queued).controlSize(.small).accessibilityIdentifier("rotation.add.\(song.id)") + }.padding(.horizontal,8).padding(.vertical,5) + .onDrag { model.dragReference(song.id).provider() } + } + if model.songs.isEmpty { Text("Import songs into Library first.").foregroundStyle(.secondary).padding(10) } + } + } + }.padding(10).background(Color.primary.opacity(0.035),in:RoundedRectangle(cornerRadius:9)) + .onAppear { if model.selectedSinger == nil { model.selectedSinger = model.venueSingers.first?.id } } + } +} diff --git a/KJamsClone/Hosting.swift b/KJamsClone/Hosting.swift new file mode 100644 index 0000000..f062a18 --- /dev/null +++ b/KJamsClone/Hosting.swift @@ -0,0 +1,214 @@ +import SwiftUI +import AppKit + +struct RotationSinger: Identifiable, Equatable { var id: Int64;var name: String;var here: Bool } +struct HostSettings: Codable, Equatable { + var smart = false + var cursor: Int64? + var fadeSeconds: Double = 3 + var autoFade = true + var intermission: Int64? +} +enum SingerRotation { + /// Stable cyclic interleave of pending performances. Empty singers retain their roster slot. + static func plan(queue: [QueueEntry],roster: [RotationSinger],after cursor: Int64?) -> [QueueEntry] { + var singers = roster.map(\.id) + if let cursor,let i = singers.firstIndex(of:cursor) { singers = Array(singers.dropFirst(i+1))+Array(singers.prefix(i+1)) } + let here = Set(roster.filter(\.here).map(\.id));singers = singers.filter{here.contains($0)} + var groups = Dictionary(grouping:queue.filter{$0.singerID != nil},by:{$0.singerID!}) + var result = [QueueEntry]() + while true { + var added = false + for singer in singers where groups[singer]?.isEmpty == false { result.append(groups[singer]!.removeFirst());added = true } + if !added { break } + } + return result+queue.filter{$0.singerID == nil} + } +} +struct ShowScreen: Identifiable, Equatable { + var id: Int64 = 0 + var title = "Welcome to karaoke" + var body = "Welcome to {venue}\n\nNext up: {next}" + var footer = "Find your next song · Enjoy the show" + var tint = "indigo" + var seconds: Double = 12 + func expanded(_ value: String,venue: String,singer: String,next: String,song: String) -> String { + value.replacingOccurrences(of:"{venue}",with:venue).replacingOccurrences(of:"{singer}",with:singer).replacingOccurrences(of:"{next}",with:next).replacingOccurrences(of:"{song}",with:song) + } + @MainActor func render(venue: String,singer: String,next: String,song: String) -> CGImage? { + let size = NSSize(width:1920,height:1080) + let image = NSImage(size:size,flipped:false) { rect in + let top: NSColor = tint == "teal" ? NSColor(srgbRed:0.02,green:0.24,blue:0.27,alpha:1) : tint == "rose" ? NSColor(srgbRed:0.26,green:0.06,blue:0.18,alpha:1) : NSColor(srgbRed:0.10,green:0.08,blue:0.28,alpha:1) + NSGradient(starting:top,ending:NSColor(srgbRed:0.015,green:0.02,blue:0.04,alpha:1))!.draw(in:rect,angle:270) + let paragraph = NSMutableParagraphStyle();paragraph.alignment = .center;paragraph.lineBreakMode = .byWordWrapping + func text(_ value: String,_ rect: NSRect,_ font: NSFont,_ color: NSColor) { (value as NSString).draw(in:rect,withAttributes:[.font:font,.foregroundColor:color,.paragraphStyle:paragraph]) } + text("KJMix Pro",NSRect(x:160,y:900,width:1600,height:60),.systemFont(ofSize:30,weight:.semibold),.white.withAlphaComponent(0.6)) + text(title,NSRect(x:160,y:730,width:1600,height:140),.systemFont(ofSize:64,weight:.bold),.white) + text(expanded(body,venue:venue,singer:singer,next:next,song:song),NSRect(x:170,y:220,width:1580,height:460),.systemFont(ofSize:48,weight:.medium),.white) + text(expanded(footer,venue:venue,singer:singer,next:next,song:song),NSRect(x:160,y:90,width:1600,height:100),.systemFont(ofSize:27,weight:.regular),.white.withAlphaComponent(0.65)) + return true + } + guard let bitmap = NSBitmapImageRep(bitmapDataPlanes:nil,pixelsWide:1920,pixelsHigh:1080,bitsPerSample:8,samplesPerPixel:4,hasAlpha:true,isPlanar:false,colorSpaceName:.deviceRGB,bytesPerRow:0,bitsPerPixel:0),let context = NSGraphicsContext(bitmapImageRep:bitmap) else { return nil } + NSGraphicsContext.saveGraphicsState();NSGraphicsContext.current = context + image.draw(in:NSRect(origin:.zero,size:size),from:.zero,operation:.copy,fraction:1) + NSGraphicsContext.restoreGraphicsState();return bitmap.cgImage + } +} + +@MainActor extension AppModel { + var rotationPlan: [QueueEntry] { hostSettings.smart ? SingerRotation.plan(queue:queue,roster:rotationSingers,after:hostSettings.cursor) : queue } + func saveHostSettings() { + perform { + guard let library,let currentVenue else { return } + try library.setSetting("host.\(currentVenue)",String(decoding:JSONEncoder().encode(hostSettings),as:UTF8.self)) + player.fadeSeconds = hostSettings.fadeSeconds;player.autoFade = hostSettings.autoFade + } + } + func markHere(_ singer: Int64,_ here: Bool) { perform { guard let library,let currentVenue else { return };try library.markHere(singer:singer,venue:currentVenue,here:here);try refresh() } } + func moveSinger(_ singer: Int64,by offset: Int) { perform { guard let library,let currentVenue else { return };try library.moveRotation(singer:singer,venue:currentVenue,by:offset);try refresh() } } + func resetRound() { hostSettings.cursor = nil;saveHostSettings() } + func assignQueue(_ entry: Int64,singer: Int64?) { perform { guard let library,let currentVenue else { return };try library.assignQueue(entry,singer:singer,venue:currentVenue);try refresh() } } + func saveScreen(_ screen: ShowScreen) { perform { try library?.saveScreen(screen);try refresh() } } + func playScreen(_ screen: ShowScreen) throws { + let next = rotationPlan.first{$0.songID != screen.id} + guard let image = screen.render(venue:currentVenueName,singer:singers.first{$0.id == playbackSinger}?.name ?? "",next:next?.singer ?? "You",song:next?.title ?? "") else { throw LibraryError.message("Could not render the slide") } + player.showScreen(image,seconds:screen.seconds,title:screen.title,songID:screen.id) + } + func advanceShow() { + if let id = hostSettings.intermission,let screen = showScreens.first(where:{$0.id == id}),!rotationPlan.isEmpty,!playingIntermission { + playingIntermission = true;perform { try playScreen(screen) } + } else { playingIntermission = false;playQueueNext() } + } + func rateSong(_ song: Song,_ rating: Int) { + perform { + guard let library else { return } + if let selectedSinger { var value = try library.preference(song:song.id,singer:selectedSinger);value.rating = rating;try library.savePreference(song:song.id,singer:selectedSinger,value:value) } + else { try library.rate(song.id,rating) };try refresh() + } + } + func displayedRating(_ song: Song) -> Int { selectedSinger == nil ? song.rating : preference(song.id).rating } + func batchMetadata(_ ids: Set,patch: MetadataPatch) throws { guard let library else { throw LibraryError.message("Library unavailable") };try library.batchMetadata(ids,patch:patch);try refresh() } +} + +struct RotationPane: View { + @ObservedObject var model: AppModel + var body: some View { RotationDashboard(model:model) } +} +struct ShowScreensPane: View { + @ObservedObject var model: AppModel + @State private var edit: ShowScreen? + var body: some View { + VStack(alignment:.leading,spacing:16) { + HStack { Text("Intermission slides").font(.headline);Spacer();Button("Create ShowScreen…") { edit = ShowScreen() } } + Text("Create timed slides that play like songs. Add them to the manual queue or choose one between singers in Rotation.").foregroundStyle(.secondary) + List(model.showScreens) { screen in + HStack { Image(systemName:"rectangle.on.rectangle").foregroundStyle(.tint);VStack(alignment:.leading) { Text(screen.title);Text("\(Int(screen.seconds)) seconds").font(.caption).foregroundStyle(.secondary) };Spacer();Button("Edit") { edit = screen };Button("Show Now") { model.perform { try model.playScreen(screen) } };Button("Queue") { model.enqueueScreen(screen.id) }.disabled(model.currentVenue == nil) } + } + }.padding(22).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).sheet(item:$edit) { screen in ShowScreenEditor(model:model,screen:screen) } + } +} +private struct ShowScreenEditor: View { + @ObservedObject var model: AppModel + @State var screen: ShowScreen + @Environment(\.dismiss) private var dismiss + var body: some View { + VStack(alignment:.leading,spacing:16) { + Text(screen.id == 0 ? "Create ShowScreen" : "Edit ShowScreen").font(.title2.bold()) + TextField("Title",text:$screen.title) + TextEditor(text:$screen.body).font(.body).frame(height:130).border(.quaternary) + TextField("Footer",text:$screen.footer) + Text("Placeholders: {venue}, {singer}, {next}, {song}").font(.caption).foregroundStyle(.secondary) + HStack { Picker("Colour",selection:$screen.tint) { Text("Indigo").tag("indigo");Text("Teal").tag("teal");Text("Rose").tag("rose") };Stepper("\(Int(screen.seconds)) seconds",value:$screen.seconds,in:3...300,step:1) } + HStack { Button("Cancel") { dismiss() };Spacer();Button("Save") { model.saveScreen(screen);if model.error == nil { dismiss() } }.keyboardShortcut(.defaultAction).disabled(screen.title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) } + }.padding(24).frame(width:530).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).textFieldStyle(.roundedBorder) + } +} + +@MainActor extension AppModel { + func enqueueScreen(_ id: Int64) { perform { try library?.enqueue(song:id,singer:nil,venue:currentVenue);try refresh() } } + func writeFileTags(_ ids: Set) { + guard !tagsBusy else { return } + guard !ids.contains(player.currentSongID ?? -1) else { error = "Stop the selected song before writing its file tags";return } + let selected = songs.filter{ids.contains($0.id)},backup = root.appendingPathComponent("TagBackups") + tagsBusy = true;tagsStatus = "Writing tags…" + Task { + var count = 0,issues = [String]() + for song in selected { + do { + guard player.currentSongID != song.id else { throw LibraryError.message("Song is now playing") } + try await FileTagWriter.write(song,backupDirectory:backup) + try library?.execute("UPDATE songs SET fingerprint='' WHERE id=?",[String(song.id)]);count += 1 + } catch { issues.append(song.title+": "+error.localizedDescription) } + } + tagsBusy = false;tagsStatus = "Tags written: \(count) · Issues: \(issues.count)" + if !issues.isEmpty { error = issues.prefix(8).joined(separator:"\n") };perform { try refresh() } + } + } +} + +struct PlaylistWorkspace: View { + @ObservedObject var model: AppModel + @Binding var query: String + @State private var selected: Int64? + @State private var name = "" + @State private var sort = "Saved Order" + @State private var ascending = true + @State private var adding = false + @State private var addIDs = Set() + @State private var addQuery = "" + @State private var delete = false + private var rows: [Song] { + let ids = selected.flatMap{try? model.library?.playlistSongIDs($0)} ?? [] + let index = Dictionary(uniqueKeysWithValues:model.songs.map{($0.id,$0)}) + let songs = ids.compactMap{index[$0]}.filter{SongSearch.matches($0,query:query)} + guard sort != "Saved Order" else { return songs } + return songs.sorted { a,b in + let first: Bool + if sort == "Duration" { first = a.duration == b.duration ? a.id < b.id : a.duration < b.duration } + else if sort == "Rating" { first = model.displayedRating(a) == model.displayedRating(b) ? a.id < b.id : model.displayedRating(a) < model.displayedRating(b) } + else { let av = sort == "Artist" ? a.artist : sort == "Album" ? a.album : a.title,bv = sort == "Artist" ? b.artist : sort == "Album" ? b.album : b.title;let comparison = av.localizedStandardCompare(bv);first = comparison == .orderedSame ? a.id < b.id : comparison == .orderedAscending } + return ascending ? first : !first + } + } + var body: some View { + VStack(alignment:.leading,spacing:16) { + HStack { + Picker("Playlist",selection:$selected) { Text("Choose a playlist…").tag(nil as Int64?);ForEach(model.playlists) { Text($0.name).tag(Optional($0.id)) } }.frame(maxWidth:300) + TextField("Playlist name",text:$name).textFieldStyle(.roundedBorder) + Button("Create") { model.createPlaylist(name);selected = model.playlists.first{$0.name == name.trimmingCharacters(in:.whitespacesAndNewlines)}?.id;name = "" }.disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + Menu { Button("Rename") { if let selected { model.rename("playlists",id:selected,name:name) } }.disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty);Button("Delete…",role:.destructive) { delete = true } } label: { Image(systemName:"ellipsis.circle") }.disabled(selected == nil) + } + HStack { + Picker("Sort",selection:$sort) { ForEach(["Saved Order","Title","Artist","Album","Duration","Rating"],id:\.self) { Text($0) } }.frame(width:200) + Button { ascending.toggle() } label: { Image(systemName:ascending ? "arrow.up" : "arrow.down") }.disabled(sort == "Saved Order").help("Reverse sort") + Text("\(rows.count) songs").font(.caption).foregroundStyle(.secondary);Spacer();Button("Add Songs…") { adding = true }.disabled(selected == nil) + } + List(Array(rows.enumerated()),id:\.element.id) { index,song in + HStack { + Text("\(index+1)").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width:22) + VStack(alignment:.leading,spacing:4) { Text(song.title);Text(song.artist+" · "+song.album).font(.caption).foregroundStyle(.secondary) };Spacer() + Button("Play") { model.perform { try model.play(song) } } + Button("Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Button { move(song.id,-1) } label: { Image(systemName:"chevron.up") }.disabled(sort != "Saved Order" || !query.isEmpty || index == 0) + Button { move(song.id,1) } label: { Image(systemName:"chevron.down") }.disabled(sort != "Saved Order" || !query.isEmpty || index == rows.count-1) + Button { model.perform { if let selected { try model.library?.removeFromPlaylist(selected,song:song.id);try model.refresh() } } } label: { Image(systemName:"minus.circle") }.help("Remove from playlist") + }.padding(.vertical,5).onDrag { model.dragReference(song.id).provider() } + }.listStyle(.inset) + Text("Use toolbar search for this playlist. Quoted phrases, artist:, album:, title:, id: and exact = searches are supported. Saved order can be changed with the arrow buttons when search is empty.").font(.caption).foregroundStyle(.secondary) + }.padding(22).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).sheet(isPresented:$adding) { addSheet } + .confirmationDialog("Delete this playlist? Library songs are kept.",isPresented:$delete) { Button("Delete Playlist",role:.destructive) { if let selected { model.delete("playlists",id:selected);self.selected = nil } } } + .onChange(of:sort) { _ in saveSort() }.onChange(of:ascending) { _ in saveSort() } + .onChange(of:selected) { id in + if let id,let value = try? model.library?.setting("playlist.sort.\(id)") { let parts = value.components(separatedBy:"|");sort = parts[0];ascending = parts.count < 2 || parts[1] == "asc" } else { sort = "Saved Order";ascending = true } + } + } + private func move(_ song: Int64,_ by: Int) { model.perform { if let selected { try model.library?.reorderPlaylist(selected,song:song,by:by);try model.refresh() } } } + private func saveSort() { if let selected { model.perform { try model.library?.setSetting("playlist.sort.\(selected)",sort+"|"+(ascending ? "asc" : "desc")) } } } + private var addSheet: some View { + VStack(alignment:.leading,spacing:16) { + Text("Add Songs to Playlist").font(.title2.bold());TextField("Search library",text:$addQuery).textFieldStyle(.roundedBorder) + List(model.songs.filter{SongSearch.matches($0,query:addQuery)}) { song in Toggle(song.title,isOn:Binding(get:{addIDs.contains(song.id)},set:{if $0 { addIDs.insert(song.id) } else { addIDs.remove(song.id) }})).toggleStyle(.checkbox) }.frame(height:320) + HStack { Button("Cancel") { adding = false };Spacer();Button("Add \(addIDs.count) Songs") { model.perform { if let selected { for id in addIDs.sorted() { try model.library?.addToPlaylist(selected,song:id) };try model.refresh() } };adding = false;addIDs = [] }.disabled(addIDs.isEmpty) } + }.padding(24).frame(width:550) + } +} diff --git a/KJamsClone/Importer.swift b/KJamsClone/Importer.swift new file mode 100644 index 0000000..6c4090a --- /dev/null +++ b/KJamsClone/Importer.swift @@ -0,0 +1,80 @@ +import Foundation +import CryptoKit +import AVFoundation + +struct Importer { + let media: URL + @discardableResult func run(_ urls: [URL],library: Library,copyMedia: Bool = true,cancelled: () -> Bool = { false }) throws -> [Int64] { + try FileManager.default.createDirectory(at: media, withIntermediateDirectories: true) + var files = urls + var temporary = [URL]() + defer { for url in temporary { try? FileManager.default.removeItem(at: url) } } + var songIDs = Set() + for zip in urls where zip.pathExtension.lowercased() == "zip" { + if cancelled() { throw CancellationError() } + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + temporary.append(folder) + // Validate names before extraction; reject path traversal and symlinks afterwards. + let details = try process(["-Z", "-l", zip.path]) + guard !details.split(separator: "\n").contains(where: { $0.hasPrefix("l") }) else { throw LibraryError.message("ZIP symlinks are unsupported") } + let list = try process(["-Z1", zip.path]) + let names = list.split(separator: "\n").map(String.init) + guard !names.isEmpty, names.allSatisfy({ !$0.hasPrefix("/") && !$0.split(separator: "/").contains("..") && !$0.contains("\\") }) else { throw LibraryError.message("Unsafe or empty ZIP") } + _ = try process(["-q", zip.path, "-d", folder.path]) + let extracted = FileManager.default.enumerator(at: folder, includingPropertiesForKeys: [.isSymbolicLinkKey,.isRegularFileKey])! + for case let url as URL in extracted { + let values = try url.resourceValues(forKeys: [.isSymbolicLinkKey,.isRegularFileKey]) + guard values.isSymbolicLink != true else { throw LibraryError.message("ZIP symlinks are unsupported") } + if values.isRegularFile == true { files.append(url) } + } + } + for cdg in files.filter({ $0.pathExtension.lowercased() == "cdg" }) { + let siblings = try FileManager.default.contentsOfDirectory(at:cdg.deletingLastPathComponent(),includingPropertiesForKeys:nil) + if let audio = siblings.first(where: { ["mp3","wav","m4a","mp4","m4v","mov"].contains($0.pathExtension.lowercased()) && $0.deletingPathExtension().lastPathComponent.caseInsensitiveCompare(cdg.deletingPathExtension().lastPathComponent) == .orderedSame }) { files.append(audio) } + } + var imported = 0 + for audio in Set(files) where ["mp3","wav","m4a","mp4","m4v","mov"].contains(audio.pathExtension.lowercased()) { + if cancelled() { throw CancellationError() } + let siblings = try FileManager.default.contentsOfDirectory(at: audio.deletingLastPathComponent(), includingPropertiesForKeys: nil) + let cdg = siblings.first { $0.pathExtension.lowercased() == "cdg" && $0.deletingPathExtension().lastPathComponent.caseInsensitiveCompare(audio.deletingPathExtension().lastPathComponent) == .orderedSame } + let hash = try audioFingerprint(audio,cancelled:cancelled) + let copy = copyMedia || temporary.contains { audio.resolvingSymlinksInPath().path.hasPrefix($0.resolvingSymlinksInPath().path+"/") } + let dest = copy ? media.appendingPathComponent(hash+"."+audio.pathExtension.lowercased()) : audio.standardizedFileURL + if copy && !FileManager.default.fileExists(atPath:dest.path) { try FileManager.default.copyItem(at:audio,to:dest) } + var graphics: String? + if let cdg { + let target = copy ? media.appendingPathComponent(hash+".cdg") : cdg.standardizedFileURL + if copy && !FileManager.default.fileExists(atPath:target.path) { try FileManager.default.copyItem(at:cdg,to:target) } + graphics = target.path + } + imported += 1 + let metadata = AVURLAsset(url:audio).commonMetadata + func tag(_ key: AVMetadataKey) -> String { metadata.first { $0.commonKey == key }?.stringValue ?? "" } + let taggedTitle = tag(.commonKeyTitle) + let id = try library.add(title:taggedTitle.isEmpty ? audio.deletingPathExtension().lastPathComponent : taggedTitle,audio:dest.path,cdg:graphics,artist:tag(.commonKeyArtist),album:tag(.commonKeyAlbumName),label:tag(.commonKeyPublisher),duration:audioDuration(audio),fingerprint:hash,preferIncomingFile:copy) + songIDs.insert(id) + } + guard imported > 0 else { throw LibraryError.message("No audio found. Keep the MP3 and matching CDG together, or import an MP3+G ZIP.") } + return Array(songIDs) + } + private func audioFingerprint(_ url: URL,cancelled: () -> Bool) throws -> String { + let file = try FileHandle(forReadingFrom:url);defer { try? file.close() } + var hasher = SHA256() + while let data = try file.read(upToCount:1_048_576),!data.isEmpty { if cancelled() { throw CancellationError() };hasher.update(data:data) } + return hasher.finalize().map { String(format:"%02x",$0) }.joined() + } + private func audioDuration(_ url: URL) -> Double { + guard let file = try? AVAudioFile(forReading:url) else { return 0 } + return Double(file.length)/file.processingFormat.sampleRate + } + private func process(_ arguments: [String]) throws -> String { + let task = Process(); task.executableURL = URL(fileURLWithPath: "/usr/bin/unzip"); task.arguments = arguments + let out = Pipe(); task.standardOutput = out + let err = FileHandle.nullDevice; task.standardError = err + try task.run() + let bytes = out.fileHandleForReading.readDataToEndOfFile(); task.waitUntilExit() + guard task.terminationStatus == 0 else { throw LibraryError.message("Cannot read ZIP archive") } + return String(decoding: bytes, as: UTF8.self) + } +} diff --git a/KJamsClone/KJMixProApp.swift b/KJamsClone/KJMixProApp.swift new file mode 100644 index 0000000..c744ba7 --- /dev/null +++ b/KJamsClone/KJMixProApp.swift @@ -0,0 +1,1556 @@ +import SwiftUI +import Combine +import UniformTypeIdentifiers +import AVFoundation +import AppKit + +enum EntityKind: String, Identifiable { + case singer, venue + var id: String { rawValue } + var title: String { self == .singer ? "Singer" : "Venue" } + var table: String { self == .singer ? "singers" : "venues" } +} + +/// Only an internal, same-process song reference is exported, never media or file URLs. +struct SongDragReference: Codable, Sendable { + static let type = UTType.plainText + var song: Int64 + var library: String + var singer: Int64? + var venue: Int64? + var fromTonight: Bool + var transportText: String { "KJMix.song.v1:"+((try? JSONEncoder().encode(self)) ?? Data()).base64EncodedString() } + func provider() -> NSItemProvider { + let provider = NSItemProvider(object:transportText as NSString) + provider.suggestedName = "KJMix song" + return provider + } + static func load(_ provider: NSItemProvider) async throws -> Self { + let text: String = try await withCheckedThrowingContinuation { continuation in + _ = provider.loadObject(ofClass:NSString.self) { object,error in + if let string = object as? String { continuation.resume(returning:string) } + else { continuation.resume(throwing:error ?? LibraryError.message("Could not read dragged song")) } + } + } + guard text.hasPrefix("KJMix.song.v1:"),let decoded = Data(base64Encoded:String(text.dropFirst("KJMix.song.v1:".count))) else { throw LibraryError.message("Drag a song from the KJMix Pro library") } + return try JSONDecoder().decode(Self.self,from:decoded) + } + +} +@MainActor final class AppModel: ObservableObject { + @Published var songs = [Song]() + @Published var singers = [PersonPlace]() + @Published var singerSongIDs = [Int64:[Int64]]() + @Published var favourites = [Int64:Set]() + @Published var musicStores = [MusicStore]() + @Published var readingDurations = false + @Published var mediaLocations = [MediaLocation]() + @Published var scanProgress: SourceScanProgress? + @Published private(set) var scanningSource: Int64? + private var scanTask: Task? + private var scanToken: UUID? + var newSingerSongs = Set() + private var songIndex = [Int64:Song]() + private let dragSession = UUID().uuidString + @Published var venues = [PersonPlace]() + @Published var venueSingers = [PersonPlace]() + @Published var playlists = [PersonPlace]() + @Published var queue = [QueueEntry]() + @Published var tonightSongIDs = [Int64:[Int64]]() + @Published var history = [Performance]() + @Published private(set) var currentVenue: Int64? + @Published var selectedSinger: Int64? { didSet { if selectedSinger != oldValue { refreshTuning() } } } + @Published private(set) var tuningCache = [Int64:SongPreference]() + @Published var expandedSingerIDs = Set() + @Published var activeSection = "Library" + @Published var studio = StudioSettings() { didSet { applyStudioSettings(save:true,previous:oldValue) } } + let backgroundDeck = BackgroundDeck() + var backgroundAutomationReady = false + var backgroundPlaybackObserver: AnyCancellable? + var backgroundHandoff: Task? + let rotationSession = UUID().uuidString + var rotationDeckSongID: Int64? + var rotationDeckVenue: Int64? + @Published var rotationPlaybackActive = false + @Published var autoAdvance = true + @Published var playbackSinger: Int64? + @Published var hostSettings = HostSettings() + @Published var rotationSingers = [RotationSinger]() + @Published var showScreens = [ShowScreen]() + @Published var tagsBusy = false + @Published var tagsStatus = "" + let singerServer = SingerWebServer() + var playingIntermission = false + @Published var error: String? + @Published var creating: EntityKind? + let player: Player + private(set) var library: Library? + let root: URL + var currentVenueName: String { venues.first { $0.id == currentVenue }?.name ?? "No venue selected" } + init(storageURL: URL? = nil) { + root = storageURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("KJamsClone") + player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms")) + perform { + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + library = try Library(url:root.appendingPathComponent("Library.sqlite")); try refresh() + if try library?.setting("store.profiles.v1") == nil { installStoreProfiles();try library?.setSetting("store.profiles.v1","1") } + } + if let data = try? Data(contentsOf:root.appendingPathComponent("StudioSettings.json")),let saved = try? JSONDecoder().decode(StudioSettings.self,from:data) { studio = saved.sanitized } + applyStudioSettings(save:false) + backgroundPlaybackObserver = player.$playing.dropFirst().sink { [weak self] _ in + Task { @MainActor [weak self] in self?.scheduleBackgroundHandoff() } + } + player.onWillStartPlayback = { [weak self] in self?.backgroundDeck.prepareForSinger() ?? 0 } + player.onPlaybackReady = { [weak self] in self?.backgroundDeck.pauseForSinger() } + player.onGlobalMixChanged = { [weak self] mix in self?.backgroundDeck.apply(mix:mix) } + player.onFinished = { [weak self] in + guard let self else { return } + guard self.autoAdvance, !self.rotationPlan.isEmpty else { self.rotationPlaybackActive = false;return } + self.advanceShow() + } + backgroundAutomationReady = true + } + func perform(_ work: () throws -> Void) { do { try work() } catch { self.error = error.localizedDescription } } + func refresh() throws { + guard let library else { throw LibraryError.message("Library unavailable") } + songs = try library.songs();songIndex = Dictionary(uniqueKeysWithValues:songs.map { ($0.id,$0) }); singers = try library.entities("singers"); venues = try library.entities("venues") + singerSongIDs = try library.singerSongIDs();favourites = try library.favouriteSongIDs();musicStores = try library.stores();mediaLocations = try library.mediaLocations() + playlists = try library.entities("playlists"); currentVenue = try library.currentVenue() + venueSingers = try currentVenue.map { try library.members(of:$0) } ?? singers + if let selectedSinger, !venueSingers.contains(where: { $0.id == selectedSinger }) { self.selectedSinger = nil } + tonightSongIDs = try library.tonightSongIDs(venue:currentVenue) + queue = try library.queue(venue:currentVenue); history = try library.history(venue:currentVenue) + showScreens = try library.screens() + if let currentVenue { + rotationSingers = try library.rotation(currentVenue) + hostSettings = try library.setting("host.\(currentVenue)").flatMap { $0.data(using:.utf8) }.flatMap { try? JSONDecoder().decode(HostSettings.self,from:$0) } ?? .init() + } else { rotationSingers = [];hostSettings = .init() } + player.fadeSeconds = hostSettings.fadeSeconds;player.autoFade = hostSettings.autoFade;refreshTuning() + } + func chooseVenue(_ id: Int64?) { + perform { + guard let library else { throw LibraryError.message("Library unavailable") } + try library.selectVenue(id); selectedSinger = nil;playingIntermission = false;try refresh() + } + } + func membership(singer: Int64, included: Bool) { + perform { + guard let library, let currentVenue else { throw LibraryError.message("Select a current venue first") } + try library.setMembership(singer:singer,venue:currentVenue,included:included); try refresh() + } + } + @discardableResult func createEntity(_ kind: EntityKind, name: String) throws -> Int64 { + guard let library else { throw LibraryError.message("Library unavailable") } + let trimmed = name.trimmingCharacters(in:.whitespacesAndNewlines) + try library.addEntity(kind.table,name:trimmed) + guard let entity = try library.entities(kind.table).first(where: { $0.name == trimmed }) else { throw LibraryError.message("Could not load new entry") } + if kind == .singer { + if let currentVenue { try library.setMembership(singer:entity.id,venue:currentVenue,included:true) } + selectedSinger = entity.id + } else { try library.selectVenue(entity.id); selectedSinger = nil } + try refresh(); return entity.id + } + func beginSinger(song: Int64? = nil) { newSingerSongs = Set(song.map { [$0] } ?? []); creating = .singer } + func songs(for singer: Int64) -> [Song] { + (singerSongIDs[singer] ?? []).compactMap { songIndex[$0] } + } + func assign(_ song: Int64, to singer: Int64, included: Bool = true) { + perform { + guard let library else { throw LibraryError.message("Library unavailable") } + if included { try library.assignSong(song,singer:singer) } else { try library.removeSingerSong(song,singer:singer) } + try refresh() + } + } + func createSinger(name: String, venue: Int64, songs: Set) throws -> Int64 { + guard let library else { throw LibraryError.message("Library unavailable") } + guard venues.contains(where: { $0.id == venue }) else { throw LibraryError.message("Choose a venue first") } + let name = name.trimmingCharacters(in:.whitespacesAndNewlines) + try library.execute("BEGIN IMMEDIATE") + let id: Int64 + do { + try library.addEntity("singers",name:name) + guard let singer = try library.entities("singers").first(where: { $0.name == name }) else { throw LibraryError.message("Could not load singer") } + id = singer.id + try library.setMembership(singer:id,venue:venue,included:true) + try library.markHere(singer:id,venue:venue,here:true) + for song in songs.sorted() { try library.addTonight(song:song,singer:id,venue:venue,transaction:false) } + try library.selectVenue(venue);try library.execute("COMMIT") + } catch { try? library.execute("ROLLBACK");throw error } + try refresh();selectedSinger = id;expandedSingerIDs.insert(id);return id + } + func rename(_ table: String, id: Int64, name: String) { perform { try library?.rename(table,id:id,name:name); try refresh() } } + func delete(_ table: String, id: Int64) { perform { try library?.deleteEntity(table,id:id); try refresh() } } + func refreshTuning() { if let library { tuningCache = (try? library.tuningSnapshot(singer:selectedSinger)) ?? [:] } } + func preference(_ song: Int64) -> SongPreference { tuningCache[song] ?? .init() } + func savePreference(song: Int64, value: SongPreference) { + perform { + guard let library, let selectedSinger else { throw LibraryError.message("Select a singer to save their song preferences") } + try library.savePreference(song:song,singer:selectedSinger,value:value); try refresh() + } + } + func play(_ song: Song, singer: Int64? = nil) throws { + rotationPlaybackActive = false;rotationDeckSongID = nil;rotationDeckVenue = nil + guard let library else { throw LibraryError.message("Library unavailable") } + let singer = singer ?? selectedSinger + if let singer, let currentVenue, try !library.members(of:currentVenue).contains(where: { $0.id == singer }) { throw LibraryError.message("This singer is not in the current venue") } + let preference = try singer.map { try library.preference(song:song.id,singer:$0) } ?? library.defaultPreference(song:song.id) + if let screen = showScreens.first(where:{$0.id == song.id}) { try playScreen(screen);return } + try player.load(song,preference:preference);playbackSinger = singer + do { try library.updateDuration(song.id,seconds:player.duration);try library.record(song.id,singer:singer,venue:currentVenue); selectedSinger = singer + if let singer,let currentVenue { hostSettings.cursor = singer;try library.setSetting("host.\(currentVenue)",String(decoding:JSONEncoder().encode(hostSettings),as:UTF8.self)) } + try refresh() } + catch { player.stop(); throw error } + } + func enqueue(_ song: Int64) { perform { try library?.enqueue(song:song,singer:selectedSinger,venue:currentVenue); try refresh() } } + func tonightSongs(for singer: Int64) -> [Song] { (tonightSongIDs[singer] ?? []).compactMap { songIndex[$0] } } + func addToTonight(_ song: Int64,singer: Int64? = nil) { + perform { + guard let library,let currentVenue,let singer = singer ?? selectedSinger else { throw LibraryError.message("Choose a current venue and singer, then add the song to Tonight") } + try library.addTonight(song:song,singer:singer,venue:currentVenue);try library.markHere(singer:singer,venue:currentVenue,here:true);try refresh() + } + } + func removeFromTonight(_ song: Int64,singer: Int64) { + perform { guard let library,let currentVenue else { return };try library.removeTonight(song:song,singer:singer,venue:currentVenue);try refresh() } + } + func playTonight(_ song: Song,singer: Int64) { + perform { + try play(song,singer:singer) + if let entry = queue.first(where:{ $0.songID == song.id && $0.singerID == singer }) { try library?.removeQueue(entry.id) } + try refresh() + } + } + func dragReference(_ song: Int64,singer: Int64? = nil,tonight: Bool = false) -> SongDragReference { + .init(song:song,library:dragSession,singer:singer,venue:currentVenue,fromTonight:tonight) + } + func placeDroppedSong(_ reference: SongDragReference,singer: Int64,venue: Int64,before: Int64?) throws { + guard let library else { throw LibraryError.message("Library unavailable") } + guard reference.library == dragSession, songIndex[reference.song] != nil else { throw LibraryError.message("Drop a song from this library") } + guard currentVenue == venue else { throw LibraryError.message("The current venue changed. Please drop again.") } + let reorder = reference.fromTonight && reference.singer == singer && reference.venue == venue && tonightSongIDs[singer]?.contains(reference.song) == true + try library.placeTonight(song:reference.song,singer:singer,venue:venue,before:before,addIfNeeded:!reorder) + if !reorder { try library.markHere(singer:singer,venue:venue,here:true) } + try refresh() + } + func acceptSongDrop(_ providers: [NSItemProvider],singer: Int64,venue: Int64?,before: Int64?) -> Bool { + guard let venue else { return false } + let providers = providers.filter { $0.hasItemConformingToTypeIdentifier(SongDragReference.type.identifier) } + guard !providers.isEmpty else { return false } + Task { @MainActor [weak self] in + guard let self else { return } + do { for provider in providers { let reference = try await SongDragReference.load(provider);try self.placeDroppedSong(reference,singer:singer,venue:venue,before:before) } } + catch { self.error = error.localizedDescription } + } + return true + } + func moveTonight(_ song: Int64,singer: Int64,by offset: Int) { + perform { + guard let library,let currentVenue,let ids = tonightSongIDs[singer],let index = ids.firstIndex(of:song),ids.indices.contains(index+offset) else { return } + let destination = index+offset + let before = offset < 0 ? ids[destination] : destination+1 < ids.count ? ids[destination+1] : nil + try library.placeTonight(song:song,singer:singer,venue:currentVenue,before:before,addIfNeeded:false);try refresh() + } + } + func playQueueNext() { + perform { + guard let row = rotationPlan.first, let song = songs.first(where: { $0.id == row.songID }) else { return } + // Explicitly clear selection for unassigned queue entries. + playingIntermission = false;selectedSinger = row.singerID + try play(song,singer:row.singerID); try library?.removeQueue(row.id); try refresh();rotationPlaybackActive = true;rotationDeckSongID = song.id;rotationDeckVenue = currentVenue + } + } + func moveQueue(_ id: Int64, by offset: Int) { perform { if let currentVenue { try library?.moveQueue(id,venue:currentVenue,by:offset); try refresh() } } } + func removeQueue(_ id: Int64) { perform { try library?.removeQueue(id); try refresh() } } + func createPlaylist(_ name: String) { perform { try library?.addEntity("playlists",name:name); try refresh() } } + func addToPlaylist(_ playlist: Int64, song: Int64) { perform { try library?.addToPlaylist(playlist,song:song); try refresh() } } + func setFavourite(_ song: Int64, singer: Int64, included: Bool) { perform { try library?.setFavourite(song:song,singer:singer,included:included);try refresh() } } + func updateTuning(_ song: Int64, pitch: Double? = nil, tempo: Double? = nil) { + perform { + guard let library else { throw LibraryError.message("Library unavailable") } + var value = preference(song) + if let pitch { value.pitch = pitch };if let tempo { value.tempo = tempo } + if let selectedSinger { try library.savePreference(song:song,singer:selectedSinger,value:value) } + else { try library.saveDefaultPreference(song:song,value:value) } + if player.currentSongID == song && playbackSinger == selectedSinger { if pitch != nil { player.pitch = value.pitch };if tempo != nil { player.tempo = value.tempo } } + tuningCache[song] = value + } + } + func saveStore(_ store: MusicStore) throws { guard let library else { throw LibraryError.message("Library unavailable") };try library.saveStore(store);try refresh() } + func deleteStore(_ id: Int64) { perform { try library?.deleteStore(id);try refresh() } } + func scanDurations() { + guard !readingDurations else { return };readingDurations = true + let songs = self.songs.filter { $0.duration == 0 } + Task.detached(priority:.utility) { [weak self] in + var durations = [(Int64,Double)]() + for song in songs { + if let file = try? AVAudioFile(forReading:URL(fileURLWithPath:song.audio)) { durations.append((song.id,Double(file.length)/file.processingFormat.sampleRate)) } + } + let completedDurations = durations + await MainActor.run { [weak self] in + guard let self else { return } + self.perform { for (id,value) in completedDurations { try self.library?.updateDuration(id,seconds:value) };try self.refresh() } + self.readingDurations = false + } + } + } + func updateMetadata(_ song: Song,title: String,artist: String,album: String,label: String,rating: Int) throws { + guard let library else { throw LibraryError.message("Library unavailable") } + try library.updateMetadata(song:song.id,title:title,artist:artist,album:album,label:label,rating:rating) + if player.currentSongID == song.id { player.title = title.trimmingCharacters(in:.whitespacesAndNewlines) } + try refresh() + } + func chooseMediaLocation() { + let panel = NSOpenPanel();panel.title = "Add Library Location";panel.message = "Choose a folder or mounted external drive. KJMix Pro will index it and prepare its waveforms." + panel.canChooseDirectories = true;panel.canChooseFiles = false;panel.allowsMultipleSelection = false;panel.canCreateDirectories = false;panel.prompt = "Add & Prepare" + panel.directoryURL = URL(fileURLWithPath:"/Volumes") + panel.begin { [weak self] response in + guard response == .OK,let url = panel.url else { return } + Task { @MainActor in + guard let self else { return } + self.perform { let id = try self.addMediaLocation(url);self.startSourceScan(id) } + } + } + } + @discardableResult func addMediaLocation(_ url: URL) throws -> Int64 { + guard let library else { throw LibraryError.message("Library unavailable") } + let access = url.startAccessingSecurityScopedResource();defer { if access { url.stopAccessingSecurityScopedResource() } } + let bookmark = (try? url.bookmarkData(options:.withSecurityScope,includingResourceValuesForKeys:nil,relativeTo:nil)) ?? (try? url.bookmarkData(options:.minimalBookmark,includingResourceValuesForKeys:nil,relativeTo:nil)) + let external = (try? url.resourceValues(forKeys:[.volumeIsInternalKey]).volumeIsInternal) == false || url.path.hasPrefix("/Volumes/") + let id = try library.saveMediaLocation(name:url.lastPathComponent.isEmpty ? "Library location" : url.lastPathComponent,url:url,bookmark:bookmark,external:external) + try refresh();return id + } + func startSourceScan(_ id: Int64,forceWaveforms: Bool = false) { + guard scanningSource == nil else { error = "A location is already being prepared. Cancel it or wait for it to finish.";return } + guard let source = mediaLocations.first(where:{ $0.id == id }) else { return } + let token = UUID();scanToken = token;scanningSource = id;scanProgress = SourceScanProgress(source:id) + let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),cache = root.appendingPathComponent("Waveforms") + scanTask = Task.detached(priority:.utility) { [weak self] in + let result: Result + do { + result = .success(try LibraryScanner.scan(source,database:database,media:media,cache:cache,forceWaveforms:forceWaveforms,cancelled:{ Task.isCancelled },progress:{ update in + Task { @MainActor [weak self] in guard let self,self.scanToken == token else { return };self.scanProgress = update } + })) + } catch { result = .failure(error) } + await MainActor.run { [weak self] in + guard let self,self.scanToken == token else { return } + switch result { + case .success(let progress):self.scanProgress = progress + case .failure(let error): + var progress = SourceScanProgress(source:id);progress.finished = true;progress.issues = 1;progress.current = error.localizedDescription;progress.messages = [error.localizedDescription];self.scanProgress = progress + try? self.library?.finishSourceScan(id,path:source.path,message:error.localizedDescription) + } + self.scanToken = nil;self.scanningSource = nil;self.scanTask = nil;self.perform { try self.refresh() } + } + } + } + func cancelSourceScan() { scanTask?.cancel();scanProgress?.current = "Cancelling after the current read/extraction…" } + func removeMediaLocation(_ id: Int64) { + guard scanningSource == nil else { error = "Finish or cancel the scan before removing a location";return } + perform { try library?.removeMediaLocation(id);try refresh() } + } + func importFiles(_ urls: [URL]) { perform { guard let library else { throw LibraryError.message("Library unavailable") }; try Importer(media:root.appendingPathComponent("Media")).run(urls,library:library); try refresh() } } +} +private struct LibraryCommands { + var importMusic: () -> Void + var songInfo: () -> Void + var canEdit: Bool + var play: () -> Void + var next: () -> Void + var video: () -> Void + var settings: () -> Void +} +private struct LibraryCommandsKey: FocusedValueKey { typealias Value = LibraryCommands } +private extension FocusedValues { + var libraryCommands: LibraryCommands? { + get { self[LibraryCommandsKey.self] } + set { self[LibraryCommandsKey.self] = newValue } + } +} +@main struct KJMixProApp: App { + @StateObject private var model = AppModel() + @FocusedValue(\.libraryCommands) private var actions + var body: some Scene { + Window("KJMix Pro", id: "main") { + LibraryView(model: model).frame(minWidth: 1060, minHeight: 760).preferredColorScheme(.dark) + }.defaultSize(width: 1320, height: 900).windowToolbarStyle(.unified) + .commands { + CommandGroup(replacing: .newItem) { + Button("Import Music…") { actions?.importMusic() }.keyboardShortcut("o").disabled(actions == nil) + Divider() + Button("New Singer…") { model.beginSinger() }.keyboardShortcut("n", modifiers: [.command, .shift]) + Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option]) + } + CommandGroup(replacing:.appSettings) { + Button("Settings…") { actions?.settings() }.keyboardShortcut(",").disabled(actions == nil) + } + CommandMenu("Library") { + Button("Song Info…") { actions?.songInfo() }.keyboardShortcut("i").disabled(actions?.canEdit != true) + Button("Add Drive or Folder…") { model.chooseMediaLocation() } + } + CommandMenu("Playback") { + Button("Play / Pause") { if model.player.duration > 0 { model.player.toggle() } else { actions?.play() } }.keyboardShortcut(.space,modifiers:[]) + Button("Stop Deck (Keep Track Loaded)") { model.player.stopDeck() }.keyboardShortcut(".",modifiers:[.command,.shift]) + Button("Return to Cue") { model.player.returnToCue() } + Button("Set Cue Here") { model.player.setCue() } + Menu("Hot Cues") { ForEach(0..<4) { index in Button("Hot Cue \(index+1)") { model.player.hotCue(index) } } } + Button("Loop In") { model.player.markLoopIn() } + Button("Loop Out") { model.player.markLoopOut() } + Button("Clear Loop") { model.player.clearLoop() } + Divider() + Button("Fade Out & Stop") { model.player.fadeOutAndStop() }.keyboardShortcut(".") + Divider() + Button("Back 10 Seconds") { model.player.seek(model.player.time-10) }.keyboardShortcut(.leftArrow) + Button("Forward 10 Seconds") { model.player.seek(model.player.time+10) }.keyboardShortcut(.rightArrow) + Button("Next Song") { actions?.next() }.disabled(actions == nil) + Divider() + Button("Show Video Output") { actions?.video() }.keyboardShortcut("v",modifiers:[.command,.shift]).disabled(actions == nil) + } + } + Window("Video", id: "lyrics") { + LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark) + }.defaultSize(width: 600, height: 400) + } +} +private enum StudioTheme { + static let frame = Color(nsColor:.windowBackgroundColor) + static let navy = Color(nsColor:.textBackgroundColor) + static let blue = Color.accentColor.opacity(0.15) + static let header = Color(nsColor:.controlBackgroundColor) + static let line = Color.primary.opacity(0.08) +} +private struct StudioSidebarLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + HStack(spacing:9) { + configuration.icon.foregroundStyle(Color(white:0.66)).frame(width:18) + configuration.title.foregroundStyle(Color(white:0.88)) + } + } +} +struct LyricsView: View { + @ObservedObject var player: Player + var body: some View { + ZStack { + Color.black + if let image = player.image { + Image(decorative: image, scale: 1).interpolation(.none).resizable().aspectRatio(contentMode: .fit).opacity(player.outputOpacity) + } + } + } +} +/// This is another view of the same published CGImage used by the Video window. +/// It owns no decoder, audio player or clock, so seeking/pause/tempo stay identical. +private struct SidebarVideoMonitor: View { + @ObservedObject var player: Player + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Label("Video Monitor", systemImage: "tv") + .font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7)) + LyricsView(player: player) + .frame(height:140) + .clipped() + .clipShape(RoundedRectangle(cornerRadius:8)).overlay(RoundedRectangle(cornerRadius:8).strokeBorder(.white.opacity(0.1),lineWidth:1)) + .accessibilityLabel("Video monitor, synchronized with the Video window") + + }.frame(maxWidth: .infinity) + } +} +private struct StudioButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label.font(.system(size:12,weight:.medium)).foregroundStyle(.primary) + .padding(.horizontal,9).padding(.vertical,5) + .background(.primary.opacity(configuration.isPressed ? 0.12 : 0.05),in:RoundedRectangle(cornerRadius:6)) + } +} +private struct TransportButton: View { + let symbol: String + let label: String + var size: CGFloat = 34 + var active = false + var enabled = true + let action: () -> Void + var body: some View { + Button(action:action) { + Image(systemName:symbol).font(.system(size:size > 40 ? 19 : 14,weight:.semibold)) + .frame(width:size,height:size) + .foregroundStyle(size > 40 ? Color.white : Color.primary) + .background(size > 40 ? Color.accentColor : Color.primary.opacity(0.055),in:Circle()) + }.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.3) + .help(label).accessibilityLabel(label) + } +} +struct LibraryView: View { + @ObservedObject var model: AppModel + @State private var selected: Int64? + @State private var tableSelection = Set() + @State private var batchEditing = false + @State private var metadataLookup: Song? + @State private var confirmWriteTags = false + @State private var search = "" + @State private var name = "" + @State private var importing = false + private var source: String { get { model.activeSection } nonmutating set { model.activeSection = newValue } } + @State private var singersExpanded = true + private var expandedSingers: Set { get { model.expandedSingerIDs } nonmutating set { model.expandedSingerIDs = newValue } } + @State private var expandedSongLists = Set() + @State private var songPickerSinger: PersonPlace? + @State private var songPickerTonight = false + @State private var singerSections = [Int64:String]() + @State private var singerSelections = [Int64:Int64]() + @State private var presentedSinger: Int64? + @State private var singerSection = "All Songs" + @State private var storesExpanded = true + @State private var focusedStore: Int64? + @State private var focusedLocation: Int64? + @State private var editingSong: Song? + @State private var libraryRows = [Song]() + @State private var tableSort = [KeyPathComparator(\Song.title)] + @State private var librarySort = "Song Name" + @State private var sortAscending = true + @Environment(\.openWindow) private var openWindow + private var visibleSongs: [Song] { libraryRows } + private func updateLibraryRows() { + let filtered = model.songs.map { source -> Song in var song = source;if model.selectedSinger != nil { song.rating = model.displayedRating(source) };return song }.filter { SongSearch.matches($0,query:search) } + libraryRows = filtered.sorted(using:tableSort) + } + + private var selectedSong: Song? { model.songs.first { $0.id == selected } } + private var workspace: some View { + HSplitView { + sidebar.frame(minWidth:220,idealWidth:240,maxWidth:260) + VStack(spacing:0) { + TransportView(player:model.player,search:$search,hasSelection:selectedSong != nil, + play:playSelected,next:playNext,display:{ openWindow(id:"lyrics") }) + .padding(20) + Divider() + HStack { + VStack(alignment:.leading,spacing:4) { + Text(source == "Singer Songs" ? (model.singers.first { $0.id == model.selectedSinger }?.name ?? "Singer")+" · "+singerSection : source).font(.title2.weight(.semibold)) + Text(source == "Library" ? "\(model.songs.count) songs in your collection" : model.currentVenueName).font(.callout).foregroundStyle(.secondary) + } + Spacer() + if source == "Library" { + Button { importing = true } label: { Label("Import Music",systemImage:"plus") }.buttonStyle(.bordered) + } + }.padding(.horizontal,22).padding(.vertical,18) + sourceContent.frame(maxWidth:.infinity,maxHeight:.infinity) + Divider() + if ["Library","Singer Songs","Playlists"].contains(source) { detailPanel.frame(height:135) } + Divider() + BackgroundDeckStrip(model:model,deck:model.backgroundDeck).padding(.horizontal,16).padding(.vertical,8) + Divider() + footer.padding(.horizontal,18).padding(.vertical,8) + }.frame(minWidth:780) + } + .background(StudioTheme.frame) + .tint(.accentColor) + .focusedSceneValue(\.libraryCommands,LibraryCommands(importMusic:{importing = true},songInfo:{editingSong = selectedSong},canEdit:selectedSong != nil,play:playSelected,next:playNext,video:{openWindow(id:"lyrics")},settings:{source = "Settings"})) + .navigationTitle("KJMix Pro") + .searchable(text:$search,placement:.toolbar,prompt:"Search Music") + .toolbar { + ToolbarItemGroup { + Button { importing = true } label: { Label("Import Music",systemImage:"square.and.arrow.down") }.help("Import music or video") + Menu { + Button("New Singer…") { model.beginSinger() } + Button("New Venue…") { model.creating = .venue } + Button("Add Drive or Folder…") { model.chooseMediaLocation() } + } label: { Label("Add",systemImage:"plus") } + Button { editingSong = selectedSong } label: { Label("Song Info",systemImage:"info.circle") }.disabled(selectedSong == nil) + Button { openWindow(id:"lyrics") } label: { Label("Video Output",systemImage:"rectangle.on.rectangle") } + Button { source = "Settings" } label: { Label("Settings",systemImage:"gearshape") } + } + } + } + var body: some View { + workspace + .onAppear { updateLibraryRows() } + .onChange(of:model.songs) { _ in updateLibraryRows() } + .onChange(of:model.tuningCache) { _ in updateLibraryRows() } + .onChange(of:search) { _ in updateLibraryRows() } + .sheet(item: $model.creating) { kind in + AddEntitySheet(model: model, kind: kind) { id in + if kind == .singer { expandedSingers.insert(id);expandedSongLists.insert(id);singersExpanded = true;showSinger(id,section:"All Songs") } else { model.chooseVenue(id) } + } + } + .sheet(isPresented:$batchEditing) { BatchMetadataSheet(model:model,ids:tableSelection) } + .sheet(item:$metadataLookup) { song in MetadataLookupSheet(model:model,song:song) } + .confirmationDialog("Write file tags for \(tableSelection.count) songs? Original files will be backed up in TagBackups before replacement. Unsupported files are reported and left unchanged.",isPresented:$confirmWriteTags) { + Button("Write File Tags") { model.writeFileTags(tableSelection) } + } + .onChange(of:tableSelection) { ids in selected = ids.sorted().first } + .sheet(item:$editingSong) { song in SongMetadataSheet(model:model,song:song) } + .sheet(item:$songPickerSinger) { singer in SingerSongsSheet(model:model,singer:singer,tonight:songPickerTonight) } + .onChange(of:selected) { song in + if source == "Singer Songs",let singer = model.selectedSinger,let song { singerSelections[singer] = song } + } + .onChange(of:model.selectedSinger) { singer in + updateLibraryRows() + if source == "Singer Songs",presentedSinger != singer { showSinger(singer,section:nil) } + } + .fileImporter(isPresented: $importing, allowedContentTypes: [.audio, .movie, .zip, UTType(filenameExtension: "cdg") ?? .data], allowsMultipleSelection: true) { result in + switch result { + case .success(let urls): model.importFiles(urls) + case .failure(let error): model.error = error.localizedDescription + } + } + .alert("KJMix Pro", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { + Button("OK") { model.error = nil } + } message: { Text(model.error ?? "") } + } + @ViewBuilder private var sourceContent: some View { + if source == "Library" { songTable } + else if source == "Queue" { queuePane } + else if source == "Settings" { StudioSettingsPane(model:model) } + else if source == "Rotation" { RotationPane(model:model) } + else if source == "ShowScreens" { ShowScreensPane(model:model) } + else if source == "Singer Web" { SingerServerPane(model:model,server:model.singerServer) } + else if source == "History" { historyPane } + else if source == "Playlists" { playlistPane } + else if source == "Singer Songs" { singerSongPane } + else if source == "Music Stores" { MusicStoresPane(model:model,selected:$focusedStore) } + else if source == "Library Locations" { LibraryLocationsPane(model:model,selected:$focusedLocation) } + else { directory } + } + private func showSinger(_ id: Int64?,section: String? = nil) { + if source == "Singer Songs",let previous = presentedSinger { + singerSections[previous] = singerSection + if let selected { singerSelections[previous] = selected } + } + presentedSinger = id;model.selectedSinger = id + singerSection = section ?? id.flatMap { singerSections[$0] } ?? "Tonight" + if let id { singerSections[id] = singerSection;selected = singerSelections[id] } + source = "Singer Songs" + } + private func sourceCount(_ source: String) -> Int { + switch source { + case "Library": return model.songs.count + case "Singers": return model.venueSingers.count + case "Venues": return model.venues.count + case "Queue": return model.queue.count + case "History": return model.history.count + default: return model.playlists.count + } + } + private var sidebar: some View { + VStack(spacing:0) { + HStack(spacing:10) { + Image("BrandMark").resizable().scaledToFit().frame(width:32,height:32) + VStack(alignment:.leading,spacing:2) { Text("KJMix Pro").font(.headline);Text("Karaoke Studio").font(.caption).foregroundStyle(.secondary) };Spacer() + }.padding(16) + ScrollView { + LazyVStack(alignment:.leading,spacing:3) { + sidebarHeading("Workspace") + navigationRow("Library",symbol:"music.note.list",count:model.songs.count) + navigationRow("Rotation",symbol:"person.3.sequence",count:model.rotationPlan.count) + navigationRow("Queue",symbol:"list.number",count:model.queue.count) + navigationRow("History",symbol:"clock",count:model.history.count) + navigationRow("Playlists",symbol:"music.note",count:model.playlists.count) + sidebarHeading("Singers · \(model.currentVenueName)") + ForEach(model.venueSingers) { singer in + VStack(spacing:2) { + HStack(spacing:0) { + Button { toggleSinger(singer.id) } label: { + Image(systemName:expandedSingers.contains(singer.id) ? "chevron.down" : "chevron.right") + .font(.system(size:10,weight:.semibold)).frame(width:28,height:34).contentShape(Rectangle()) + }.buttonStyle(.plain).accessibilityLabel("\(expandedSingers.contains(singer.id) ? "Collapse" : "Expand") \(singer.name)").accessibilityValue(expandedSingers.contains(singer.id) ? "Expanded" : "Collapsed").accessibilityIdentifier("singer.expand.\(singer.id)") + Button { expandedSingers.insert(singer.id);showSinger(singer.id) } label: { + HStack(spacing:7) { Image(systemName:"person").foregroundStyle(.secondary);Text(singer.name).lineLimit(1);Spacer() }.frame(height:34).contentShape(Rectangle()) + }.buttonStyle(.plain).modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:nil,enabled:true)) + Button { expandedSingers.insert(singer.id);showSinger(singer.id,section:"Tonight");songPickerTonight = true;songPickerSinger = singer } label: { Image(systemName:"plus").frame(width:25,height:30) }.buttonStyle(.plain).help("Add songs for \(singer.name)") + }.background(source == "Singer Songs" && model.selectedSinger == singer.id ? Color.accentColor.opacity(0.13) : Color.clear,in:RoundedRectangle(cornerRadius:7)) + if expandedSingers.contains(singer.id) { + singerDestination("Tonight",symbol:"moon.stars",singer:singer) + .modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:nil,enabled:true)) + singerDestination("Favourites",symbol:"star",singer:singer) + singerDestination("History",symbol:"clock",singer:singer) + singerDestination("All Songs",symbol:"music.note.list",singer:singer) + } + } + } + Button { model.beginSinger() } label: { Label("Add Singer…",systemImage:"plus").frame(maxWidth:.infinity,alignment:.leading).padding(9) }.buttonStyle(.plain).foregroundStyle(.secondary) + sidebarHeading("Manage") + navigationRow("Venues",symbol:"building.2",count:model.venues.count) + navigationRow("Library Locations",symbol:"externaldrive",count:model.mediaLocations.count) + navigationRow("ShowScreens",symbol:"rectangle.on.rectangle") + navigationRow("Singer Web",symbol:"network") + navigationRow("Music Stores",symbol:"bag") + navigationRow("Settings",symbol:"gearshape") + }.font(.system(size:13)).padding(.horizontal,10).padding(.bottom,12) + } + Divider().padding(.horizontal,14) + SidebarVideoMonitor(player:model.player).padding(14) + }.background(.ultraThinMaterial) + } + private func sidebarHeading(_ value: String) -> some View { Text(value).font(.system(size:11,weight:.semibold)).foregroundStyle(.secondary).lineLimit(1).padding(.top,14).padding(.bottom,5).padding(.horizontal,9) } + private func navigationRow(_ value: String,symbol: String,count: Int? = nil) -> some View { + Button { source = value } label: { + HStack(spacing:9) { Image(systemName:symbol).frame(width:18).foregroundStyle(source == value ? Color.accentColor : Color.secondary);Text(value);Spacer();if let count { Text("\(count)").font(.caption.monospacedDigit()).foregroundStyle(.secondary) } }.padding(.horizontal,9).frame(height:34).contentShape(Rectangle()) + }.buttonStyle(.plain).accessibilityIdentifier("nav."+value).background(source == value ? Color.accentColor.opacity(0.15) : .clear,in:RoundedRectangle(cornerRadius:7)) + } + private func singerDestination(_ value: String,symbol: String,singer: PersonPlace) -> some View { + Button { showSinger(singer.id,section:value) } label: { + HStack(spacing:8) { Image(systemName:symbol).frame(width:16).foregroundStyle(.secondary);Text(value);Spacer() }.padding(.leading,35).padding(.trailing,9).frame(height:29).contentShape(Rectangle()) + }.buttonStyle(.plain).background(source == "Singer Songs" && model.selectedSinger == singer.id && singerSection == value ? Color.accentColor.opacity(0.15) : .clear,in:RoundedRectangle(cornerRadius:6)) + } + private func toggleSinger(_ id: Int64) { if expandedSingers.contains(id) { expandedSingers.remove(id) } else { expandedSingers.insert(id) } } + private var singerSongPane: some View { + VStack(alignment:.leading,spacing:12) { + if let singer = model.singers.first(where:{ $0.id == model.selectedSinger }) { + HStack { Text("\(singer.name) · \(singerSection)").font(.headline);Spacer();Button(singerSection == "Tonight" ? "Add to Tonight…" : "Add Songs…") { songPickerTonight = singerSection == "Tonight";songPickerSinger = singer } } + Text("\(model.currentVenueName) · Singer song lists follow them across venues.").font(.caption).foregroundStyle(.secondary) + if singerSection == "History" { + ScrollView { LazyVStack(alignment:.leading,spacing:12) { ForEach(model.history.filter { $0.singerID == singer.id }) { row in HStack { Text(row.date).font(.caption.monospaced());Text(row.song);Spacer() } } } } + } else if singerSection == "Tonight" { + ScrollView { + VStack(spacing:10) { + ForEach(Array(model.tonightSongs(for:singer.id).enumerated()),id:\.element.id) { index,song in + HStack { + Image(systemName:"line.3.horizontal").foregroundStyle(.secondary).help("Drag to reorder Tonight") + Text("\(index+1)").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width:18) + Button { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } label: { GoldStar(filled:model.favourites[singer.id]?.contains(song.id) == true,size:15) }.buttonStyle(.plain).help("Toggle favourite") + Text(song.title).lineLimit(1);Spacer() + Text(model.queue.contains(where:{ $0.songID == song.id && $0.singerID == singer.id }) ? "Queued" : "Ready").font(.caption).foregroundStyle(.secondary) + Button("Play") { model.playTonight(song,singer:singer.id) } + Button("Queue Again") { model.selectedSinger = singer.id;model.enqueue(song.id) } + Button { model.moveTonight(song.id,singer:singer.id,by:-1) } label: { Image(systemName:"arrow.up") }.disabled(index == 0).help("Move earlier") + Button { model.moveTonight(song.id,singer:singer.id,by:1) } label: { Image(systemName:"arrow.down") }.disabled(index == model.tonightSongs(for:singer.id).count-1).help("Move later") + Button("Remove") { model.removeFromTonight(song.id,singer:singer.id) } + }.padding(8).background(.white.opacity(0.035)).contentShape(Rectangle()) + .onDrag { model.dragReference(song.id,singer:singer.id,tonight:true).provider() } + .modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:song.id,enabled:true)) + } + Text(model.tonightSongs(for:singer.id).isEmpty ? "Drop a library song here to add it to \(singer.name)’s Tonight." : "Drop here to append · Drop on a row to place before it") + .font(.callout).foregroundStyle(.secondary).frame(maxWidth:.infinity).padding(18) + .overlay(RoundedRectangle(cornerRadius:4).strokeBorder(.white.opacity(0.16),style:StrokeStyle(lineWidth:1,dash:[5,4]))) + .modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:nil,enabled:true)) + } + } + } else { + ScrollView { + VStack(spacing:10) { + ForEach(model.songs(for:singer.id).filter { singerSection != "Favourites" || model.favourites[singer.id]?.contains($0.id) == true }) { song in + HStack { + Button { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } label: { GoldStar(filled:model.favourites[singer.id]?.contains(song.id) == true,size:15) }.buttonStyle(.plain).help("Favourite for this singer") + Text(song.title).lineLimit(1);Spacer() + Button("Play") { model.selectedSinger = singer.id;model.perform { try model.play(song,singer:singer.id) } } + Button("Add to Tonight") { model.addToTonight(song.id,singer:singer.id) }.disabled(model.currentVenue == nil) + Button("Remove") { model.assign(song.id,to:singer.id,included:false) } + }.padding(8).background(.white.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = song.id } + .onDrag { model.dragReference(song.id).provider() } + } + if model.songs(for:singer.id).isEmpty { Text("Use Add Songs… or the + beside this singer in the sidebar.").foregroundStyle(.secondary).padding(.top,25) } + } + } + } + } else { Text("Choose a singer from the sidebar.").foregroundStyle(.secondary);Spacer() } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line)) + } + private var songTable: some View { + Table(visibleSongs,selection:$tableSelection,sortOrder:Binding(get:{tableSort},set:{tableSort = $0;updateLibraryRows()})) { + TableColumn("ID",value:\Song.id) { song in + HStack(spacing:5) { NativeSongDragHandle(reference:model.dragReference(song.id),title:song.title).frame(width:14,height:24);Text("\(song.id)").foregroundStyle(.tertiary).monospacedDigit() } + }.width(min:48,ideal:55,max:75) + TableColumn("Title",value:\Song.title) { song in + HStack(spacing:8) { + if model.player.currentSongID == song.id { Image(systemName:"waveform").foregroundStyle(Color.accentColor).font(.caption) } + Text(song.title).fontWeight(.medium).lineLimit(1) + }.padding(.vertical,5).onDrag { model.dragReference(song.id).provider() } + }.width(min:180,ideal:220,max:600) + TableColumn("Artist",value:\Song.artist) { song in Text(song.artist.isEmpty ? "—" : song.artist).foregroundStyle(.secondary) }.width(min:90,ideal:110,max:240) + TableColumn("Album",value:\Song.album) { song in Text(song.album.isEmpty ? "—" : song.album).foregroundStyle(.secondary) }.width(min:85,ideal:95,max:220) + TableColumn("Time",value:\Song.duration) { song in Text(durationLabel(song.duration)).monospacedDigit().foregroundStyle(.secondary) }.width(50) + TableColumn("Key") { song in + HStack(spacing:2) { + Text(String(format:"%+.0f",model.preference(song.id).pitch)).monospacedDigit().frame(width:24) + Stepper("Key",value:Binding(get:{model.preference(song.id).pitch},set:{model.updateTuning(song.id,pitch:$0)}),in:-12...12,step:1).labelsHidden().controlSize(.mini) + }.help("Saved key for this singer or song") + }.width(65) + TableColumn("Tempo") { song in + Picker("Tempo",selection:Binding(get:{model.preference(song.id).tempo},set:{model.updateTuning(song.id,tempo:$0)})) { + ForEach(10..<31) { Text("\($0*5)%").tag(Double($0)/20) } + }.labelsHidden().controlSize(.small) + }.width(78) + TableColumn("Rating",value:\Song.rating) { song in + GoldRating(rating:Binding(get:{model.displayedRating(song)},set:{model.rateSong(song,$0)})) + }.width(94) + }.tableStyle(.inset(alternatesRowBackgrounds:true)) + .contextMenu(forSelectionType:Int64.self) { ids in + if ids.count > 1 { Button("Edit \(ids.count) Songs…") { tableSelection = ids;batchEditing = true } } + else if let id = ids.first,let song = model.songs.first(where:{$0.id == id}) { songActions(song) } + } primaryAction: { ids in if let id = ids.first { selected = id;tableSelection = [id];playSelected() } } + .overlay { + if visibleSongs.isEmpty { + VStack(spacing:12) { + Image(systemName:search.isEmpty ? "music.note.list" : "magnifyingglass").font(.system(size:34,weight:.light)).foregroundStyle(.tertiary) + Text(search.isEmpty ? "Your stage starts here" : "No matching songs").font(.headline) + Text(search.isEmpty ? "Import music or add a drive to build your library." : "Try a different title, artist or album.").foregroundStyle(.secondary) + if search.isEmpty { Button("Import Music…") { importing = true }.buttonStyle(.borderedProminent) } + }.padding(24) + } + } + } + @ViewBuilder private func songActions(_ song: Song) -> some View { + Button("Play") { selected = song.id;playSelected() } + Button("Song Info…") { editingSong = song } + Button("Find Metadata Online…") { metadataLookup = song } + Button("Batch Edit Selected…") { if tableSelection.isEmpty { tableSelection = [song.id] };batchEditing = true } + Button("Write File Tags…") { tableSelection = [song.id];confirmWriteTags = true }.disabled(model.tagsBusy || song.audio.hasPrefix("showscreen:")) + Divider() + Menu("Add to Tonight") { ForEach(model.venueSingers) { singer in Button(singer.name) { model.addToTonight(song.id,singer:singer.id) } } }.disabled(model.currentVenue == nil) + Menu("Add to Singer") { + ForEach(model.venueSingers) { singer in Button(singer.name) { model.assign(song.id,to:singer.id);model.selectedSinger = singer.id;expandedSingers.insert(singer.id) } } + Divider();Button("New Singer with This Song…") { model.beginSinger(song:song.id) } + } + Button("Add to Venue Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Menu("Add to Playlist") { ForEach(model.playlists) { playlist in Button(playlist.name) { model.addToPlaylist(playlist.id,song:song.id) } } } + } + private func durationLabel(_ seconds: Double) -> String { seconds > 0 ? String(format:"%d:%02d",Int(seconds)/60,Int(seconds)%60) : "—" } + @State private var managingRoster = false + @State private var rosterNewName = "" + @State private var editingEntity: PersonPlace? + @State private var renameText = "" + @State private var pendingDelete: PersonPlace? + private var directory: some View { + VStack(alignment:.leading,spacing:0) { + HStack { + Text(source == "Singers" ? "Singers · \(model.currentVenueName)" : "Venues").font(.headline) + Spacer() + if source == "Singers" { Button("Manage Venue Singers…") { managingRoster = true }.disabled(model.currentVenue == nil) } + Button(source == "Singers" ? "New Singer…" : "New Venue…") { if source == "Singers" { model.beginSinger() } else { model.creating = .venue } } + }.padding(12).background(StudioTheme.header) + ScrollView { + VStack(alignment:.leading,spacing:0) { + if source == "Singers" && model.venueSingers.isEmpty { + Text("No singers at this venue. Add a singer or use Manage Venue Singers to include existing singers.").foregroundStyle(.secondary).padding() + } + ForEach(source == "Singers" ? model.venueSingers : model.venues) { entity in + HStack { + Button { + if source == "Singers" { model.selectedSinger = entity.id } else { model.chooseVenue(entity.id) } + } label: { + HStack { + Image(systemName:source == "Singers" ? "person.fill" : "building.2.fill") + Text(entity.name); Spacer() + if entity.id == (source == "Singers" ? model.selectedSinger : model.currentVenue) { + Text(source == "Singers" ? "Selected" : "Current venue").font(.caption).foregroundStyle(.cyan) + Image(systemName:"checkmark.circle.fill").foregroundStyle(.cyan) + } + }.contentShape(Rectangle()) + }.buttonStyle(.plain) + Button("Rename") { renameText = entity.name; editingEntity = entity } + Button("Delete",role:.destructive) { pendingDelete = entity } + }.padding(12) + } + } + } + HStack { + TextField(source == "Singers" ? "Singer name" : "Venue name",text:$name).textFieldStyle(.roundedBorder).onSubmit(addEntity) + Button(source == "Singers" ? "Add Singer" : "Add Venue",action:addEntity).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + }.padding(12) + }.foregroundStyle(.white).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line)) + .sheet(isPresented:$managingRoster) { rosterSheet } + .sheet(item:$editingEntity) { entity in + VStack(alignment:.leading,spacing:16) { + Text("Rename \(source == "Singers" ? "Singer" : "Venue")").font(.headline) + TextField("Name",text:$renameText).textFieldStyle(.roundedBorder) + HStack { Button("Cancel") { editingEntity = nil }; Spacer(); Button("Save") { model.rename(source == "Singers" ? "singers" : "venues",id:entity.id,name:renameText); editingEntity = nil }.disabled(renameText.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) } + }.padding(24).frame(width:360) + } + .confirmationDialog("Delete \(pendingDelete?.name ?? "entry")? Historical plays are kept. Venue memberships and queued entries for it will be removed.",isPresented:Binding(get:{ pendingDelete != nil },set:{ if !$0 { pendingDelete = nil } })) { + Button("Delete",role:.destructive) { if let entity = pendingDelete { model.delete(source == "Singers" ? "singers" : "venues",id:entity.id) }; pendingDelete = nil } + } + } + private var rosterSheet: some View { + VStack(alignment:.leading,spacing:16) { + Text("Singers at \(model.currentVenueName)").font(.title2.bold()) + Text("A singer can belong to any number of venues. Unchecking removes them from this venue only.").foregroundStyle(.secondary) + ScrollView { + VStack(alignment:.leading,spacing:12) { + ForEach(model.singers) { singer in + Toggle(singer.name,isOn:Binding(get:{ model.venueSingers.contains { $0.id == singer.id } },set:{ model.membership(singer:singer.id,included:$0) })) + } + }.frame(maxWidth:.infinity,alignment:.leading) + }.frame(minHeight:120,maxHeight:280) + HStack { + TextField("New singer name",text:$rosterNewName).textFieldStyle(.roundedBorder) + Button("Add") { model.perform { _ = try model.createEntity(.singer,name:rosterNewName); rosterNewName = "" } }.disabled(rosterNewName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + Spacer(); Button("Done") { managingRoster = false }.keyboardShortcut(.defaultAction) + } + }.padding(24).frame(width:460) + } + private var detailPanel: some View { + VStack(alignment:.leading,spacing:9) { + HStack(spacing:10) { + Picker("Current venue",selection:Binding(get:{ model.currentVenue },set:{ model.chooseVenue($0) })) { + Text("Select venue…").tag(nil as Int64?) + ForEach(model.venues) { Text($0.name).tag(Optional($0.id)) } + } + Button { model.creating = .venue } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add venue") + Picker("Singer",selection:$model.selectedSinger) { + Text("None").tag(nil as Int64?) + ForEach(model.venueSingers) { Text($0.name).tag(Optional($0.id)) } + } + Button { model.beginSinger() } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add singer") + Menu("Add to Singer") { + ForEach(model.venueSingers) { singer in Button(singer.name) { if let selected { model.assign(selected,to:singer.id);model.selectedSinger = singer.id;expandedSingers.insert(singer.id) } } } + Divider();Button("New Singer + Song…") { model.beginSinger(song:selected) } + }.disabled(selected == nil) + Button("Add to Tonight") { if let selected { model.addToTonight(selected) } }.disabled(selected == nil || model.currentVenue == nil || model.selectedSinger == nil) + } + PlaybackSettings(player:model.player,model:model,selectedSong:selected) + SeekBar(player:model.player) + }.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity) + .background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line)) + } + @State private var playlistName = "" + @State private var selectedPlaylist: Int64? + private var queuePane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { Text("Queue · \(model.currentVenueName)").font(.headline); Spacer(); Toggle("Auto play next",isOn:$model.studio.autoAdvance).toggleStyle(.checkbox); Button("Play Next") { model.playQueueNext() }.disabled(model.queue.isEmpty) } + ScrollView { + VStack(spacing:0) { + ForEach(Array(model.queue.enumerated()),id:\.element.id) { index,row in + HStack { + Text("\(index+1)").foregroundStyle(.secondary).frame(width:24) + Text(row.singer).frame(width:140,alignment:.leading) + Text(row.title).lineLimit(1); Spacer() + Button { model.moveQueue(row.id,by:-1) } label: { Image(systemName:"arrow.up") }.disabled(index == 0) + Button { model.moveQueue(row.id,by:1) } label: { Image(systemName:"arrow.down") }.disabled(index == model.queue.count-1) + Picker("Singer",selection:Binding(get:{row.singerID},set:{model.assignQueue(row.id,singer:$0)})) { Text("No singer / Filler").tag(nil as Int64?);ForEach(model.venueSingers) { Text($0.name).tag(Optional($0.id)) } }.labelsHidden().frame(width:130) + Button("Remove") { model.removeQueue(row.id) } + }.padding(.vertical,8) + } + if model.queue.isEmpty { Text("Choose a venue and singer, select a library song, then click Queue Song.").foregroundStyle(.secondary).padding(.top,30) } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line)) + } + @State private var historyAllVenues = false + private var displayedHistory: [Performance] { + if historyAllVenues { return (try? model.library?.history(venue:nil,singer:model.selectedSinger)) ?? [] } + return model.history.filter { model.selectedSinger == nil || $0.singer == model.singers.first(where: { $0.id == model.selectedSinger })?.name } + } + private var historyPane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { Text("History · \(historyAllVenues || model.currentVenue == nil ? "All venues" : model.currentVenueName)").font(.headline); Spacer(); Toggle("All venues",isOn:$historyAllVenues).toggleStyle(.checkbox) } + ScrollView { + LazyVStack(alignment:.leading,spacing:12) { + ForEach(displayedHistory) { row in + HStack { Text(row.date).font(.system(.caption,design:.monospaced)); Text(row.singer).frame(width:130,alignment:.leading); Text(row.song); Spacer(); Text(row.venue).foregroundStyle(.secondary) } + } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line)) + } + private var playlistPane: some View { PlaylistWorkspace(model:model,query:$search) } + private var footer: some View { + HStack(spacing:8) { + Circle().fill(Color.green.opacity(0.85)).frame(width:5,height:5) + Text("\(visibleSongs.count) songs").font(.caption).foregroundStyle(.secondary) + if !model.tagsStatus.isEmpty { Text(model.tagsStatus).font(.caption).foregroundStyle(.secondary).lineLimit(1) } + Spacer() + if tableSelection.count > 1 { Button("Edit \(tableSelection.count) Songs…") { batchEditing = true }.controlSize(.small) } + Text(model.currentVenueName).font(.caption).foregroundStyle(.secondary) + RepeatButton(player:model.player).buttonStyle(.borderless) + Menu { + Button("Batch Edit Selected…") { batchEditing = true }.disabled(tableSelection.isEmpty) + Button("Find Metadata Online…") { metadataLookup = selectedSong }.disabled(selectedSong == nil) + Button("Write File Tags…") { confirmWriteTags = true }.disabled(tableSelection.isEmpty || model.tagsBusy) + Divider() + Button("Read Missing Durations") { model.scanDurations() }.disabled(model.readingDurations) + Button("Song Info…") { editingSong = selectedSong }.disabled(selectedSong == nil) + } label: { Image(systemName:"ellipsis.circle") }.menuStyle(.borderlessButton).frame(width:20).help("Library actions") + } + } + private func playSelected() { + guard let song = selectedSong else { return } + model.perform { try model.play(song) } + } + private func playNext() { + guard !visibleSongs.isEmpty else { return } + let index = visibleSongs.firstIndex { $0.id == selected } ?? -1 + selected = visibleSongs[(index + 1) % visibleSongs.count].id; playSelected() + } + private func addEntity() { + model.perform { + let kind: EntityKind = source == "Singers" ? .singer : .venue + let id = try model.createEntity(kind, name: name) + if kind == .singer { model.selectedSinger = id } else { model.chooseVenue(id) } + name = "" + } + } +} +/// AppKit starts this drag directly, so row click/double-click recognisers cannot swallow it. +private struct NativeSongDragHandle: NSViewRepresentable { + let reference: SongDragReference + let title: String + func makeNSView(context: Context) -> NativeSongDragView { let view = NativeSongDragView();view.reference = reference;view.title = title;return view } + func updateNSView(_ view: NativeSongDragView,context: Context) { view.reference = reference;view.title = title } +} +private final class NativeSongDragView: NSView,NSDraggingSource { + var reference: SongDragReference? + var title = "Song" + private var start: NSPoint? + private var dragging = false + override func draw(_ dirtyRect: NSRect) { + NSColor.secondaryLabelColor.setStroke() + let path = NSBezierPath();path.lineWidth = 1 + for y in [7.0,10.0,13.0] { path.move(to:NSPoint(x:3,y:y));path.line(to:NSPoint(x:13,y:y)) };path.stroke() + } + override func resetCursorRects() { addCursorRect(bounds,cursor:.openHand) } + override func mouseDown(with event: NSEvent) { start = event.locationInWindow;dragging = false } + override func mouseDragged(with event: NSEvent) { + guard !dragging,let reference else { return } + if let start,hypot(event.locationInWindow.x-start.x,event.locationInWindow.y-start.y) < 3 { return } + dragging = true + let item = NSDraggingItem(pasteboardWriter:reference.transportText as NSString) + let text = title + let image = NSImage(size:NSSize(width:240,height:28),flipped:false) { rect in + NSColor.windowBackgroundColor.setFill();NSBezierPath(roundedRect:rect,xRadius:5,yRadius:5).fill() + let paragraph = NSMutableParagraphStyle();paragraph.lineBreakMode = .byTruncatingTail + (text as NSString).draw(in:NSRect(x:10,y:6,width:220,height:18),withAttributes:[.font:NSFont.systemFont(ofSize:12),.foregroundColor:NSColor.labelColor,.paragraphStyle:paragraph]);return true + } + item.setDraggingFrame(NSRect(origin:convert(event.locationInWindow,from:nil),size:NSSize(width:240,height:28)),contents:image) + beginDraggingSession(with:[item],event:event,source:self).animatesToStartingPositionsOnCancelOrFail = true + } + override func mouseUp(with event: NSEvent) { start = nil;dragging = false } + func draggingSession(_ session: NSDraggingSession,sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { [.copy,.move] } + func draggingSession(_ session: NSDraggingSession,endedAt screenPoint: NSPoint,operation: NSDragOperation) { start = nil;dragging = false } +} +private struct TonightSongDropTarget: ViewModifier { + @ObservedObject var model: AppModel + let singer: Int64 + let venue: Int64? + let before: Int64? + let enabled: Bool + @State private var targeted = false + func body(content: Content) -> some View { + content.contentShape(Rectangle()) + .background(targeted && enabled ? Color.cyan.opacity(0.14) : .clear) + .overlay(alignment:.top) { if targeted && enabled { Rectangle().fill(.cyan).frame(height:2) } } + .onDrop(of:enabled && venue != nil ? [SongDragReference.type] : [],isTargeted:$targeted) { providers in + guard enabled else { return false } + return model.acceptSongDrop(providers,singer:singer,venue:venue,before:before) + } + } +} +/// Drawn with literal colours, so native menu/button tint cannot turn favourite stars white. +private struct GoldStar: View { + var filled: Bool + var size: CGFloat = 14 + var body: some View { + Canvas { context,bounds in + let centre = CGPoint(x:bounds.width/2,y:bounds.height/2),outer = min(bounds.width,bounds.height)*0.46 + var star = Path() + for point in 0..<10 { + let angle = Double(point)*Double.pi/5-Double.pi/2,radius = point % 2 == 0 ? outer : outer*0.43 + let p = CGPoint(x:centre.x+CGFloat(cos(angle))*radius,y:centre.y+CGFloat(sin(angle))*radius) + if point == 0 { star.move(to:p) } else { star.addLine(to:p) } + } + star.closeSubpath() + let gold = Color(red:1,green:0.73,blue:0.08) + if filled { context.fill(star,with:.color(gold)) } + else { context.stroke(star,with:.color(gold.opacity(0.7)),lineWidth:1) } + }.frame(width:size,height:size).accessibilityHidden(true) + } +} +private struct GoldRating: View { + @Binding var rating: Int + var body: some View { + HStack(spacing:2) { ForEach(1..<6) { value in Button { rating = rating == value ? 0 : value } label: { GoldStar(filled:value <= rating) }.buttonStyle(.plain).help("\(value) stars; click the selected rating to clear").accessibilityLabel("Set \(value) stars") } } + } +} +private struct SongMetadataSheet: View { + @ObservedObject var model: AppModel + let song: Song + @Environment(\.dismiss) private var dismiss + @State private var title = "" + @State private var artist = "" + @State private var album = "" + @State private var label = "" + @State private var rating = 0 + @State private var error: String? + var body: some View { + VStack(alignment:.leading,spacing:14) { + Text("Edit Song Metadata").font(.title2.bold()) + Grid(alignment:.leading,horizontalSpacing:12,verticalSpacing:10) { + GridRow { Text("Title");TextField("Title",text:$title) } + GridRow { Text("Artist");TextField("Artist",text:$artist) } + GridRow { Text("Album");TextField("Album",text:$album) } + GridRow { Text("Label");TextField("Label / publisher",text:$label) } + GridRow { Text("Rating");GoldRating(rating:$rating) } + }.textFieldStyle(.roundedBorder) + Text("Saved to your library. Embedded audio-file tags are not changed, and re-importing will preserve your library edits.").font(.caption).foregroundStyle(.secondary) + Text(URL(fileURLWithPath:song.audio).lastPathComponent).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle).help(song.audio) + if let error { Text(error).foregroundStyle(.red).font(.callout) } + HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction);Button("Save") { save() }.keyboardShortcut(.defaultAction).disabled(title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) } + }.padding(24).frame(width:500).background(StudioTheme.frame).preferredColorScheme(.dark) + .onAppear { title = song.title;artist = song.artist;album = song.album;label = song.label;rating = song.rating } + } + private func save() { do { try model.updateMetadata(song,title:title,artist:artist,album:album,label:label,rating:rating);dismiss() } catch { self.error = error.localizedDescription } } +} +private struct LibraryLocationsPane: View { + @ObservedObject var model: AppModel + @Binding var selected: Int64? + @State private var removing: Int64? + var body: some View { + VStack(alignment:.leading,spacing:14) { + HStack { Text("Library Locations").font(.headline);Spacer();Button("Add Drive / Folder…") { model.chooseMediaLocation() }.disabled(model.scanningSource != nil) } + Text("Index a mounted drive or folder in place and prepare its waveforms. Reconnect external drives to play their referenced audio. ZIP contents are copied into managed media for playback.").font(.callout).foregroundStyle(.secondary) + ScrollView { + VStack(alignment:.leading,spacing:12) { + ForEach(model.mediaLocations) { location in + VStack(alignment:.leading,spacing:8) { + HStack { + Image(systemName:location.external ? "externaldrive.fill" : "folder.fill").foregroundStyle(.cyan) + Text(location.name).font(.headline) + Text(location.online ? "Online" : "Offline").font(.caption).foregroundStyle(location.online ? Color.green : Color.orange) + Spacer();Text("\(location.songCount) songs").font(.caption).foregroundStyle(.secondary) + } + Text(location.path).font(.caption.monospaced()).foregroundStyle(.secondary).textSelection(.enabled) + HStack { + Button("Scan / Prepare") { model.startSourceScan(location.id) }.disabled(model.scanningSource != nil) + Button("Rebuild Waveforms") { model.startSourceScan(location.id,forceWaveforms:true) }.disabled(model.scanningSource != nil) + Spacer();Button("Remove Location",role:.destructive) { removing = location.id }.disabled(model.scanningSource != nil) + } + if !location.lastScan.isEmpty { Text("Last scan: \(location.lastScan)").font(.caption).foregroundStyle(.secondary) } + if !location.lastError.isEmpty { Text(location.lastError).font(.caption).foregroundStyle(.orange) } + }.padding(12).background(selected == location.id ? Color.blue.opacity(0.13) : Color.white.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = location.id } + } + if model.mediaLocations.isEmpty { Text("No locations added. Choose Add Drive / Folder… to index an external drive or music folder.").foregroundStyle(.secondary).padding(.vertical,25) } + } + } + if let progress = model.scanProgress { + Divider() + HStack { Text(progress.current).font(.callout).lineLimit(1);Spacer();if model.scanningSource != nil { ProgressView().controlSize(.small);Button("Cancel") { model.cancelSourceScan() } } } + Text("\(progress.files) files scanned · \(progress.indexed) songs indexed · \(progress.generated) waveforms built · \(progress.cached) cached · \(progress.issues) issues").font(.caption).foregroundStyle(.secondary) + if !progress.messages.isEmpty { DisclosureGroup("Scan details") { ScrollView { Text(progress.messages.joined(separator:"\n")).font(.caption.monospaced()).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading) }.frame(maxHeight:100) } } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line)) + .confirmationDialog("Remove this location? Its files and library songs will be kept.",isPresented:Binding(get:{ removing != nil },set:{ if !$0 { removing = nil } })) { + Button("Remove Location",role:.destructive) { if let removing { model.removeMediaLocation(removing) };removing = nil } + } + } +} +private struct MusicStoresPane: View { + @ObservedObject var model: AppModel + @Binding var selected: Int64? + @Environment(\.openURL) private var openURL + @State private var editor = false + @State private var editing: MusicStore? + @State private var catalogue: MusicStore? + @State private var search = "" + @State private var deleting: Int64? + var body: some View { + VStack(alignment:.leading,spacing:14) { + HStack { Text("Music Stores").font(.headline);Spacer();Button("Add All Provider Profiles") { model.installStoreProfiles() };Button("Add Store…") { editing = nil;editor = true } } + Text("Define websites and optional catalogue-search URLs. Purchases and subscriptions are handled by the store in your browser.").font(.callout).foregroundStyle(.secondary) + HStack { TextField("Search store catalogue",text:$search).textFieldStyle(.roundedBorder);Text("{query} in a store’s search URL is replaced with this text.").font(.caption).foregroundStyle(.secondary) } + ScrollView { + VStack(alignment:.leading,spacing:12) { + ForEach(model.musicStores) { store in + VStack(alignment:.leading,spacing:8) { + HStack { Image(systemName:"bag.fill").foregroundStyle(.green);Text(store.name).font(.headline);Spacer() + Button("Catalogue…") { catalogue = store } + Button("Open Store") { open(store,query:"") } + Button("Search") { open(store,query:search) }.disabled(search.isEmpty || store.searchTemplate.isEmpty) + Button("Edit") { editing = store;editor = true } + Button("Delete",role:.destructive) { deleting = store.id } + } + Text(store.url).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + }.padding(12).background(selected == store.id ? Color.blue.opacity(0.15) : Color.white.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = store.id } + } + if model.musicStores.isEmpty { Text("No stores configured. Add a store name and website address to get started.").foregroundStyle(.secondary).padding(.top,20) } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line)) + .sheet(isPresented:$editor) { MusicStoreEditor(model:model,store:editing) } + .sheet(item:$catalogue) { store in StoreCatalogueSheet(model:model,store:store) } + .confirmationDialog("Delete this store definition?",isPresented:Binding(get:{ deleting != nil },set:{ if !$0 { deleting = nil } })) { + Button("Delete",role:.destructive) { if let deleting { model.deleteStore(deleting) };deleting = nil } + } + } + private func open(_ store: MusicStore,query: String) { + do { openURL(try store.destination(query:query)) } catch { model.error = error.localizedDescription } + } +} +private struct MusicStoreEditor: View { + @ObservedObject var model: AppModel + let store: MusicStore? + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var url = "" + @State private var search = "" + @State private var error: String? + var body: some View { + VStack(alignment:.leading,spacing:14) { + Text(store == nil ? "Add Music Store" : "Edit Music Store").font(.title2.bold()) + TextField("Store name",text:$name).textFieldStyle(.roundedBorder) + TextField("Website URL — https://example.com",text:$url).textFieldStyle(.roundedBorder) + TextField("Optional search URL — https://example.com/search?q={query}",text:$search).textFieldStyle(.roundedBorder) + Text("Use {query} where the search words belong. Only http:// and https:// websites are accepted.").font(.caption).foregroundStyle(.secondary) + if let error { Text(error).foregroundStyle(.red).font(.callout) } + HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction);Button("Save") { save() }.keyboardShortcut(.defaultAction).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty || url.isEmpty) } + }.padding(24).frame(width:500).background(StudioTheme.frame).preferredColorScheme(.dark) + .onAppear { name = store?.name ?? "";url = store?.url ?? "";search = store?.searchTemplate ?? "" } + } + private func save() { do { try model.saveStore(.init(id:store?.id ?? 0,name:name,url:url,searchTemplate:search));dismiss() } catch { self.error = error.localizedDescription } } +} +private struct SingerSongsSheet: View { + @ObservedObject var model: AppModel + let singer: PersonPlace + var tonight = false + @Environment(\.dismiss) private var dismiss + @State private var search = "" + @State private var importing = false + var body: some View { + VStack(alignment:.leading,spacing:14) { + Text(tonight ? "Tonight for \(singer.name)" : "Songs for \(singer.name)").font(.title2.bold()) + Text(tonight ? "Tick songs for this venue’s Tonight list. They stay here after playback and when switching singers." : "Tick songs to add them to this singer’s repertoire.").font(.callout).foregroundStyle(.secondary) + TextField("Search library",text:$search).textFieldStyle(.roundedBorder) + ScrollView { + LazyVStack(alignment:.leading,spacing:12) { + ForEach(model.songs.filter { search.isEmpty || $0.title.localizedCaseInsensitiveContains(search) }) { song in + HStack { + Toggle(song.title,isOn:Binding(get:{ tonight ? model.tonightSongIDs[singer.id]?.contains(song.id) == true : model.singerSongIDs[singer.id]?.contains(song.id) == true },set:{ included in + if tonight { if included { model.addToTonight(song.id,singer:singer.id) } else { model.removeFromTonight(song.id,singer:singer.id) } } + else { model.assign(song.id,to:singer.id,included:included) } + })).toggleStyle(.checkbox) + Spacer() + Button { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } label: { GoldStar(filled:model.favourites[singer.id]?.contains(song.id) == true,size:15) }.buttonStyle(.plain).help("Toggle favourite") + } + } + if model.songs.isEmpty { Text("Your library is empty. Import audio or MP3+G below.").foregroundStyle(.secondary) } + }.frame(maxWidth:.infinity,alignment:.leading) + }.frame(height:270) + HStack { Button("Import Songs…") { importing = true };Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) } + }.padding(24).frame(width:520).background(StudioTheme.frame).preferredColorScheme(.dark) + .fileImporter(isPresented:$importing,allowedContentTypes:[.audio,.movie,.zip,UTType(filenameExtension:"cdg") ?? .data],allowsMultipleSelection:true) { result in + switch result { case .success(let urls):model.importFiles(urls);case .failure(let error):model.error = error.localizedDescription } + } + } +} +private struct AddEntitySheet: View { + @ObservedObject var model: AppModel + let kind: EntityKind + let saved: (Int64) -> Void + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var error: String? + @State private var venue: Int64? + @State private var songs = Set() + @State private var search = "" + @FocusState private var focused: Bool + var body: some View { + VStack(alignment:.leading,spacing:16) { + Text(kind == .singer ? "New Singer + Songs" : "New Venue").font(.title2.bold()) + TextField("\(kind.title) name",text:$name).textFieldStyle(.roundedBorder).focused($focused).onSubmit(save) + if kind == .singer { + Picker("Venue",selection:$venue) { Text("Choose a venue…").tag(nil as Int64?);ForEach(model.venues) { Text($0.name).tag(Optional($0.id)) } } + Text(venue == nil ? "Create a venue first, then add a singer to it." : "Singer will be marked Here. Selected songs join Tonight and the rotation.").font(.caption).foregroundStyle(.secondary) + Text("Add songs to Tonight (optional)").font(.headline) + TextField("Search library",text:$search).textFieldStyle(.roundedBorder) + ScrollView { + LazyVStack(alignment:.leading,spacing:10) { + ForEach(model.songs.filter { search.isEmpty || $0.title.localizedCaseInsensitiveContains(search) }) { song in + Toggle(song.title,isOn:Binding(get:{ songs.contains(song.id) },set:{ if $0 { songs.insert(song.id) } else { songs.remove(song.id) } })).toggleStyle(.checkbox) + } + if model.songs.isEmpty { Text("No library songs yet. You can add songs from the singer’s + button after importing.").font(.callout).foregroundStyle(.secondary) } + }.frame(maxWidth:.infinity,alignment:.leading) + }.frame(height:190) + } + if let error { Text(error).foregroundStyle(.red).font(.callout) } + HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Button(kind == .singer ? "Add Singer & Songs" : "Add Venue",action:save).keyboardShortcut(.defaultAction) + .disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty || (kind == .singer && venue == nil)) + } + }.padding(24).frame(width:kind == .singer ? 500 : 360).background(StudioTheme.frame).preferredColorScheme(.dark) + .onAppear { focused = true;venue = model.currentVenue ?? model.venues.first?.id;songs = model.newSingerSongs } + } + private func save() { + do { + let id: Int64 + if kind == .singer { guard let venue else { throw LibraryError.message("Choose a venue") };id = try model.createSinger(name:name,venue:venue,songs:songs) } + else { id = try model.createEntity(kind,name:name) } + saved(id);dismiss() + } catch { self.error = error.localizedDescription } + } +} +private struct TransportView: View { + @ObservedObject var player: Player + @Binding var search: String + let hasSelection: Bool + let play: () -> Void + let next: () -> Void + let display: () -> Void + var body: some View { + HStack(spacing:24) { + VStack(spacing:18) { + HStack(spacing:8) { + TransportButton(symbol:"gobackward.10",label:"Back 10 seconds",size:28,enabled:player.duration > 0) { player.seek(player.time-10) } + TransportButton(symbol:player.playing ? "pause.fill" : "play.fill",label:player.playing ? "Pause" : "Play",size:48,enabled:hasSelection || player.duration > 0) { + if player.duration > 0 { player.toggle() } else { play() } + } + TransportButton(symbol:"stop.fill",label:"Stop",size:28,enabled:player.duration > 0) { player.stopDeck() } + TransportButton(symbol:"goforward.10",label:"Forward 10 seconds",size:28,enabled:player.duration > 0) { player.seek(player.time+10) } + TransportButton(symbol:"forward.end.fill",label:"Next song",size:26,enabled:hasSelection,action:next) + } + HStack(spacing:8) { + Image(systemName:"speaker.fill").font(.caption2).foregroundStyle(.secondary) + Slider(value:$player.volume,in:0...1).controlSize(.small).accessibilityLabel("Global master volume") + Image(systemName:"speaker.wave.3.fill").font(.caption2).foregroundStyle(.secondary) + }.frame(width:172) + }.frame(width:200) + NowPlayingWaveform(player:player) + }.frame(height:174) + } +} + +private struct NowPlayingWaveform: View { + @ObservedObject var player: Player + @State private var mixer = false + @State private var zoom: Double = 1 + @State private var hoverX: CGFloat? + @State private var scrubStart: Double? + @State private var resumeAfterScrub = false + @State private var scrubSong: Int64? + private var span: Double { max(0.001,player.duration/zoom) } + private var windowStart: Double { scrubStart ?? min(max(0,player.time-span/2),max(0,player.duration-span)) } + private var progress: Double { player.duration > 0 ? min(1,max(0,(player.time-windowStart)/span)) : 0 } + private func clock(_ value: Double) -> String { let n = max(0,Int(value));return String(format:"%d:%02d",n/60,n%60) } + private func preciseClock(_ value: Double) -> String { String(format:"%d:%05.2f",max(0,Int(value))/60,max(0,value).truncatingRemainder(dividingBy:60)) } + private func bandColor(_ index: Int) -> Color { + guard player.waveformBands.indices.contains(index) else { return .cyan } + let band = player.waveformBands[index] + let largest = max(0.000001,max(band.low,max(band.mid,band.high))) + let low = Double(band.low/largest),mid = Double(band.mid/largest),high = Double(band.high/largest) + return Color(red:min(1,pow(low,1.2)+mid*0.12+high*0.6),green:min(1,low*0.08+pow(mid,1.5)*0.97+high*0.1),blue:min(1,low*0.07+mid*0.22+high)) + } + var body: some View { + VStack(alignment:.leading,spacing:5) { + Text(player.duration > 0 ? player.title : "Ready to play").font(.system(size:13,weight:.semibold)).foregroundStyle(Color(white:0.94)) + .lineLimit(1).truncationMode(.middle).frame(maxWidth:.infinity,alignment:.leading).help(player.title) + GeometryReader { geometry in + Canvas { context,size in + let top: CGFloat = 10,plotHeight = size.height-top,mid = top+plotHeight/2 + var centre = Path();centre.move(to:CGPoint(x:0,y:mid));centre.addLine(to:CGPoint(x:size.width,y:mid)) + context.stroke(centre,with:.color(.white.opacity(0.13)),lineWidth:0.5) + if player.duration > 0 { + let step = [0.1,0.25,0.5,1.0,2,5,10,15,30,60,120,300,600,1800,3600].first { $0 >= span/5 } ?? 3600 + let first = floor(windowStart/(step/4))*(step/4) + for t in stride(from:first,through:min(player.duration,windowStart+span),by:step/4) { + let x = size.width*(t-windowStart)/span + if x < 0 { continue } + let major = abs(t/step-(t/step).rounded()) < 0.001 + var tick = Path();tick.move(to:CGPoint(x:x,y:top));tick.addLine(to:CGPoint(x:x,y:size.height)) + context.stroke(tick,with:.color(major ? .yellow.opacity(0.65) : .white.opacity(0.16)),lineWidth:major ? 0.8 : 0.5) + if major && x < size.width-25 { + context.draw(Text(span < 10 ? preciseClock(t) : clock(t)).font(.system(size:8,design:.monospaced)).foregroundColor(.gray),at:CGPoint(x:x+3,y:3),anchor:.leading) + } + } + } + if !player.waveform.isEmpty { + let count = max(1,Int(size.width/2)),step = size.width/CGFloat(count) + for bar in 0.. 0,geometry.size.width > 0 else { return } + if scrubStart == nil { + scrubStart = windowStart;scrubSong = player.currentSongID;resumeAfterScrub = player.playing + if player.playing { player.toggle() } + } + guard scrubSong == player.currentSongID else { return } + hoverX = value.location.x + let target = windowStart+Double(min(1,max(0,value.location.x/geometry.size.width)))*span + if abs(target-player.time) > 0.02 { player.seek(target) } + }.onEnded { value in + if scrubStart == nil { + if player.duration > 0 { player.seek(windowStart+Double(min(1,max(0,value.location.x/max(1,geometry.size.width))))*span) } + return + } + let target = windowStart+Double(min(1,max(0,value.location.x/max(1,geometry.size.width))))*span + if scrubSong == player.currentSongID { + player.seek(target) + if resumeAfterScrub && !player.playing && target < player.duration-0.01 { player.toggle() } + } + scrubStart = nil;resumeAfterScrub = false;scrubSong = nil + }).help("Hover for exact time. Drag to scrub; playback resumes on release. Zoom for fine positioning.") + .accessibilityLabel("Audio waveform").accessibilityValue("\(clock(player.time)) of \(clock(player.duration))") + .accessibilityAdjustableAction { direction in if direction == .increment { player.seek(player.time+5) } else if direction == .decrement { player.seek(player.time-5) } } + }.frame(height:60) + HStack(spacing:5) { + Circle().fill(player.playing ? Color.green : Color.orange).frame(width:4,height:4) + Text(player.duration == 0 ? "NO TRACK LOADED" : player.playing ? "PLAYING" : "PAUSED").tracking(1) + if player.waveformUnavailable { Text("· Waveform unavailable").foregroundStyle(.secondary) } + Spacer(minLength:0) + Button { zoom = max(1,zoom/2) } label: { Image(systemName:"minus.magnifyingglass") }.buttonStyle(.plain).disabled(zoom <= 1).help("Zoom out") + Button { zoom = min(32,zoom*2) } label: { Image(systemName:"plus.magnifyingglass") }.buttonStyle(.plain).disabled(zoom >= 32).help("Zoom in for precise scrubbing") + Menu("\(Int(zoom))×") { ForEach([1.0,2,4,8,16,32],id:\.self) { value in Button("\(Int(value))× zoom") { zoom = value } } }.menuStyle(.borderlessButton).frame(width:34).help("Waveform zoom around the playhead") + Menu { Button("Re-analyse Waveform") { player.reanalyseWaveform() }.disabled(player.duration == 0) + Text(player.waveformLoading ? "Analysing…" : player.waveformFromCache ? "Loaded saved waveform" : player.waveformSaved ? "Analysis saved" : "No saved analysis") + } label: { Image(systemName:"waveform") }.menuStyle(.borderlessButton).frame(width:20).help("Waveform cache and re-analysis") + Button("Mixer") { mixer.toggle() }.buttonStyle(.plain).foregroundStyle(.white.opacity(0.85)) + .padding(.horizontal,6).padding(.vertical,2).background(.white.opacity(0.08),in:RoundedRectangle(cornerRadius:3)) + .popover(isPresented:$mixer) { ToneMixerView(player:player) } + Text("\(clock(player.time)) / \(clock(player.duration))").monospacedDigit().padding(.leading,5) + }.font(.system(size:9,weight:.medium)).foregroundStyle(Color(white:0.6)) + DJDeckControls(player:player) + }.padding(.horizontal,12).padding(.vertical,9).frame(maxWidth:.infinity).frame(height:174) + .background(Color.black.opacity(0.22),in:RoundedRectangle(cornerRadius:12)).overlay(RoundedRectangle(cornerRadius:12).strokeBorder(.white.opacity(0.07),lineWidth:1)) + } +} +private struct ToneKnob: View { + let title: String + @Binding var value: Double + let range: ClosedRange + var filter = false + @State private var initial: Double? + private var fraction: Double { min(1,max(0,(value-range.lowerBound)/(range.upperBound-range.lowerBound))) } + private var display: String { + if filter { return abs(value) < 0.01 ? "OFF" : value < 0 ? "LOW PASS" : "HIGH PASS" } + return String(format:"%+.1f dB",value) + } + var body: some View { + VStack(spacing:4) { + ZStack { + Circle().trim(from:0,to:0.75).stroke(Color(white:0.21),style:StrokeStyle(lineWidth:4,lineCap:.round)).rotationEffect(.degrees(135)) + Circle().trim(from:0,to:0.75*fraction).stroke(Color(white:0.48),style:StrokeStyle(lineWidth:3,lineCap:.round)).rotationEffect(.degrees(135)) + Circle().fill(LinearGradient(colors:[Color(white:0.19),Color(white:0.09)],startPoint:.top,endPoint:.bottom)).padding(6).overlay(Circle().stroke(.black.opacity(0.65),lineWidth:1.5).padding(6)) + Capsule().fill(.white).frame(width:2,height:12).offset(y:-13).rotationEffect(.degrees(-135+270*fraction)) + }.frame(width:50,height:50).contentShape(Circle()) + .gesture(DragGesture(minimumDistance:0).onChanged { gesture in + if initial == nil { initial = value } + value = min(range.upperBound,max(range.lowerBound,(initial ?? value)-Double(gesture.translation.height)/130*(range.upperBound-range.lowerBound))) + }.onEnded { _ in initial = nil }) + .simultaneousGesture(TapGesture(count:2).onEnded { value = 0 }) + .accessibilityElement().accessibilityLabel(title).accessibilityValue(display) + .accessibilityAdjustableAction { direction in + let change = (range.upperBound-range.lowerBound)/24 + value = min(range.upperBound,max(range.lowerBound,value+(direction == .increment ? change : -change))) + }.help("Drag up/down to adjust. Double-click to reset.") + Text(title).font(.system(size:10,weight:.bold)).foregroundStyle(Color(white:0.6)) + Text(display).font(.system(size:8,design:.monospaced)).foregroundStyle(Color(white:0.45)) + } + } +} +private struct StereoMeter: View { + @ObservedObject var player: Player + private func normal(_ level: Float) -> Double { max(0,min(1,(20*log10(Double(max(level,0.000001)))+60)/60)) } + var body: some View { + HStack(spacing:3) { + ForEach(0..<2) { channel in + VStack(spacing:2) { + ForEach((0..<24).reversed(),id:\.self) { segment in + let on = Double(segment)/24 < normal(channel == 0 ? player.leftLevel : player.rightLevel) + Rectangle().fill(on ? (segment > 21 ? Color.red : segment > 17 ? Color.yellow : Color.green) : Color.black.opacity(0.5)) + } + } + } + }.frame(width:23,height:125).accessibilityLabel("Stereo output levels") + } +} +private struct MixerFader: View { + @Binding var value: Double + var body: some View { + GeometryReader { geometry in + let travel = geometry.size.height-22 + ZStack(alignment:.top) { + VStack { ForEach(0..<7) { _ in Rectangle().fill(.white.opacity(0.09)).frame(height:1);Spacer(minLength:0) } }.padding(.vertical,11) + Capsule().fill(.black.opacity(0.8)).frame(width:5).padding(.vertical,8) + RoundedRectangle(cornerRadius:5).fill(LinearGradient(colors:[Color(white:0.45),Color(white:0.23)],startPoint:.top,endPoint:.bottom)) + .frame(width:45,height:20).overlay(Rectangle().fill(.white.opacity(0.15)).frame(width:18,height:1)) + .offset(y:(1-value)*travel) + }.frame(maxWidth:.infinity).contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance:0).onChanged { value = min(1,max(0,1-Double(($0.location.y-11)/travel))) }) + }.frame(width:56,height:135).accessibilityLabel("Master volume").accessibilityValue("\(Int(value*100)) percent") + .accessibilityAdjustableAction { direction in value = min(1,max(0,value+(direction == .increment ? 0.05 : -0.05))) } + } +} +private struct ToneMixerView: View { + @ObservedObject var player: Player + var body: some View { + VStack(spacing:14) { + Text("Global settings · Saved automatically").font(.caption).foregroundStyle(.secondary) + HStack { Text("GLOBAL TONE / MASTER").font(.system(size:12,weight:.bold));Spacer();Toggle("EQ",isOn:$player.toneEnabled).toggleStyle(.switch).controlSize(.mini) } + HStack(alignment:.top,spacing:28) { + VStack(spacing:8) { + ToneKnob(title:"HIGH",value:$player.highGain,range:-12...12) + ToneKnob(title:"MID",value:$player.midGain,range:-12...12) + ToneKnob(title:"LOW",value:$player.lowGain,range:-12...12) + } + VStack(spacing:15) { + ToneKnob(title:"FILTER",value:$player.filter,range:-1...1,filter:true) + HStack(spacing:20) { StereoMeter(player:player);MixerFader(value:$player.volume) } + Text(player.volume <= 0 ? "−∞ dB" : String(format:"%+.1f dB",20*log10(player.volume))).font(.system(size:10,design:.monospaced)).foregroundStyle(.secondary) + } + }.padding(.horizontal,15) + HStack { Text("Drag knobs · Double-click to reset").font(.system(size:9)).foregroundStyle(.secondary);Spacer();Button("Reset EQ") { player.resetTone() }.controlSize(.small) } + }.padding(18).frame(width:270).background(Color(white:0.105)).preferredColorScheme(.dark) + } +} +private struct PlaybackSettings: View { + @ObservedObject var player: Player + @ObservedObject var model: AppModel + let selectedSong: Int64? + private var target: Int64? { selectedSong ?? player.currentSongID } + private var pitch: Binding { Binding(get:{target.map{model.preference($0).pitch} ?? 0},set:{value in if let target { model.updateTuning(target,pitch:value) }}) } + private var tempo: Binding { Binding(get:{target.map{model.preference($0).tempo} ?? 1},set:{value in if let target { model.updateTuning(target,tempo:value) }}) } + private var rating: Binding { Binding(get:{target.flatMap { id in model.songs.first{$0.id == id} }.map{model.displayedRating($0)} ?? 0},set:{value in if let song = model.songs.first(where:{$0.id == target}) { model.rateSong(song,value) }}) } + var body: some View { + HStack(spacing:10) { + Text("Key") + Picker("Key",selection:pitch) { ForEach(-12..<13) { Text(String(format:"%+d",$0)).tag(Double($0)) } }.labelsHidden().frame(width:70) + Text("Tempo");Slider(value:tempo,in:0.5...1.5,step:0.05).frame(width:85) + Text(String(format:"%.0f%%",tempo.wrappedValue*100)).monospacedDigit().frame(width:40) + Text("Rating");GoldRating(rating:rating).frame(width:88) + Text(model.selectedSinger == nil ? "Song defaults · Saved" : "Singer settings · Saved").font(.caption).foregroundStyle(.secondary) + Menu { + Button("Reset key / tempo") { pitch.wrappedValue = 0;tempo.wrappedValue = 1 } + ForEach([-1.0,-0.5,0,0.5,1.0],id:\.self) { offset in Button(String(format:"Graphics offset %+.1fs",offset)) { player.graphicsOffset = offset } } + } label: { Image(systemName:"slider.horizontal.3") }.menuStyle(.borderlessButton).frame(width:22) + }.font(.system(size:11)).disabled(target == nil) + } +} +private struct SeekBar: View { + @ObservedObject var player: Player + private func clock(_ seconds: Double) -> String { let n = Int(seconds); return String(format: "%d:%02d", n / 60, n % 60) } + var body: some View { + HStack(spacing: 10) { + Text(clock(player.time)) + Slider(value: Binding(get: { player.time }, set: { player.seek($0) }), in: 0...max(1, player.duration)) + .controlSize(.mini).tint(.gray).disabled(player.duration == 0).accessibilityLabel("Playback position") + Text(clock(player.duration)) + }.font(.system(size: 10, design: .monospaced)).foregroundStyle(.white.opacity(0.65)) + } +} +private struct RepeatButton: View { + @ObservedObject var player: Player + var body: some View { + Button { player.repeating.toggle() } label: { + Image(systemName: "repeat").font(.system(size: 13, weight: .medium)) + .foregroundStyle(player.repeating ? Color.accentColor : Color.secondary).frame(width: 32, height: 24) + }.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off") + } +} + diff --git a/KJamsClone/Library.swift b/KJamsClone/Library.swift new file mode 100644 index 0000000..8ff9eb7 --- /dev/null +++ b/KJamsClone/Library.swift @@ -0,0 +1,538 @@ +import Foundation +import SQLite3 + +struct Song: Identifiable, Hashable { + var id: Int64 + var title: String + var audio: String + var cdg: String? + var rating: Int + var plays: Int + var artist: String = "" + var album: String = "" + var label: String = "" + var duration: Double = 0 + var fingerprint: String = "" +} +struct MediaLocation: Identifiable, Hashable, Sendable { + var id: Int64 + var name: String + var path: String + var bookmark: Data? + var external: Bool + var lastScan: String + var lastError: String + var songCount: Int + var online: Bool { var directory: ObjCBool = false;return FileManager.default.fileExists(atPath:path,isDirectory:&directory) && directory.boolValue } +} +struct IndexedSourceFile { + var size: Int64 + var modified: Double + var songs: [Int64] +} +struct MusicStore: Identifiable, Hashable { + var id: Int64 + var name: String + var url: String + var searchTemplate: String + func destination(query: String = "") throws -> URL { + let query = query.trimmingCharacters(in:.whitespacesAndNewlines) + let encoded = query.addingPercentEncoding(withAllowedCharacters:CharacterSet(charactersIn:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")) ?? "" + let value = query.isEmpty || searchTemplate.isEmpty ? url : searchTemplate.replacingOccurrences(of:"{query}",with:encoded) + guard let result = URL(string:value), ["http","https"].contains(result.scheme?.lowercased() ?? ""), result.host?.isEmpty == false, result.user == nil, result.password == nil else { throw LibraryError.message("Use a valid http:// or https:// store URL without credentials") } + return result + } +} +struct PersonPlace: Identifiable, Hashable { var id: Int64; var name: String } +struct SongPreference: Equatable, Codable { + var pitch: Double = 0 + var tempo: Double = 1 + var rating: Int = 0 +} +struct Performance: Identifiable { + var id: Int64 + var song: String + var singer: String + var venue: String + var date: String + var singerID: Int64? + var songID: Int64? +} +struct QueueEntry: Identifiable { + var id: Int64 + var songID: Int64 + var singerID: Int64? + var title: String + var singer: String +} +enum LibraryError: LocalizedError { + case message(String) + var errorDescription: String? { if case .message(let s) = self { return s }; return nil } +} +final class Library { + private var db: OpaquePointer? + init(url: URL) throws { + guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw LibraryError.message("Cannot open library") } + sqlite3_busy_timeout(db,5000) + guard sqlite3_exec(db,"PRAGMA journal_mode=WAL",nil,nil,nil) == SQLITE_OK else { throw error() } + try execute("PRAGMA foreign_keys=ON") + try execute("CREATE TABLE IF NOT EXISTS songs(id INTEGER PRIMARY KEY,title TEXT NOT NULL,audio TEXT UNIQUE NOT NULL,cdg TEXT,rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),plays INTEGER NOT NULL DEFAULT 0)") + try execute("CREATE TABLE IF NOT EXISTS singers(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS venues(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS history(id INTEGER PRIMARY KEY,song INTEGER REFERENCES songs(id),singer INTEGER REFERENCES singers(id),venue INTEGER REFERENCES venues(id),played TEXT DEFAULT CURRENT_TIMESTAMP)") + // Additive migration: preserve older libraries and infer existing memberships from history. + for column in ["artist", "album", "label"] { + if try !columns("songs").contains(column) { try execute("ALTER TABLE songs ADD COLUMN \(column) TEXT NOT NULL DEFAULT ''") } + } + try execute("CREATE TABLE IF NOT EXISTS venue_singers(venue INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,PRIMARY KEY(venue,singer))") + if try schemaVersion() < 3 { try execute("INSERT OR IGNORE INTO venue_singers SELECT DISTINCT venue,singer FROM history WHERE venue IS NOT NULL AND singer IS NOT NULL") } + try execute("CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT)") + try execute("CREATE TABLE IF NOT EXISTS preferences(song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5),rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),PRIMARY KEY(song,singer))") + try execute("CREATE TABLE IF NOT EXISTS queue(id INTEGER PRIMARY KEY,venue INTEGER REFERENCES venues(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER REFERENCES singers(id) ON DELETE CASCADE,position INTEGER NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS playlists(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS playlist_songs(playlist INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(playlist,song))") + try execute("CREATE TABLE IF NOT EXISTS singer_songs(singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(singer,song))") + if try schemaVersion() < 4 { + try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,MIN(id) FROM history WHERE singer IS NOT NULL AND song IS NOT NULL GROUP BY singer,song") + try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,0 FROM preferences") + } + if try !columns("songs").contains("duration") { try execute("ALTER TABLE songs ADD COLUMN duration REAL NOT NULL DEFAULT 0") } + if try !columns("singer_songs").contains("favourite") { try execute("ALTER TABLE singer_songs ADD COLUMN favourite INTEGER NOT NULL DEFAULT 0 CHECK(favourite IN (0,1))") } + if try schemaVersion() < 5 { try execute("UPDATE singer_songs SET favourite=1 WHERE EXISTS(SELECT 1 FROM preferences WHERE preferences.singer=singer_songs.singer AND preferences.song=singer_songs.song AND preferences.rating>=4)") } + try execute("CREATE TABLE IF NOT EXISTS song_settings(song INTEGER PRIMARY KEY REFERENCES songs(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5))") + try execute("CREATE TABLE IF NOT EXISTS music_stores(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL,url TEXT NOT NULL,search_template TEXT NOT NULL DEFAULT '')") + if try !columns("songs").contains("metadata_edited") { try execute("ALTER TABLE songs ADD COLUMN metadata_edited INTEGER NOT NULL DEFAULT 0") } + try execute("CREATE TABLE IF NOT EXISTS singer_tonight(venue INTEGER NOT NULL,singer INTEGER NOT NULL,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(venue,singer,song),FOREIGN KEY(venue,singer) REFERENCES venue_singers(venue,singer) ON DELETE CASCADE)") + if try schemaVersion() < 7 { + try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) SELECT queue.venue,queue.singer,queue.song,MIN(queue.position) FROM queue JOIN venue_singers ON venue_singers.venue=queue.venue AND venue_singers.singer=queue.singer WHERE queue.singer IS NOT NULL GROUP BY queue.venue,queue.singer,queue.song") + } + if try !columns("songs").contains("fingerprint") { try execute("ALTER TABLE songs ADD COLUMN fingerprint TEXT NOT NULL DEFAULT ''") } + try execute("CREATE INDEX IF NOT EXISTS songs_fingerprint ON songs(fingerprint)") + try execute("CREATE TABLE IF NOT EXISTS media_sources(id INTEGER PRIMARY KEY,name TEXT NOT NULL,path TEXT NOT NULL UNIQUE,bookmark TEXT,external INTEGER NOT NULL DEFAULT 0,last_scan TEXT NOT NULL DEFAULT '',last_error TEXT NOT NULL DEFAULT '')") + try execute("CREATE TABLE IF NOT EXISTS source_files(source INTEGER NOT NULL REFERENCES media_sources(id) ON DELETE CASCADE,path TEXT NOT NULL,size INTEGER NOT NULL,modified REAL NOT NULL,PRIMARY KEY(source,path))") + try execute("CREATE TABLE IF NOT EXISTS source_songs(source INTEGER NOT NULL,path TEXT NOT NULL,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,PRIMARY KEY(source,path,song),FOREIGN KEY(source,path) REFERENCES source_files(source,path) ON DELETE CASCADE)") + if try schemaVersion() < 8 { + for song in try songs() where song.fingerprint.isEmpty { + let url = URL(fileURLWithPath:song.audio),hash = URL(fileURLWithPath:song.audio).deletingPathExtension().lastPathComponent + if url.deletingLastPathComponent().lastPathComponent == "Media",hash.count == 64,hash.allSatisfy({ $0.isHexDigit }) { try execute("UPDATE songs SET fingerprint=? WHERE id=?",[hash,String(song.id)]) } + } + } + try execute("CREATE TABLE IF NOT EXISTS singer_rotation(venue INTEGER NOT NULL,singer INTEGER NOT NULL,position INTEGER NOT NULL,here INTEGER NOT NULL DEFAULT 1,PRIMARY KEY(venue,singer),FOREIGN KEY(venue,singer) REFERENCES venue_singers(venue,singer) ON DELETE CASCADE)") + try execute("CREATE TABLE IF NOT EXISTS show_screens(song INTEGER PRIMARY KEY REFERENCES songs(id) ON DELETE CASCADE,body TEXT NOT NULL,footer TEXT NOT NULL,tint TEXT NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS singer_access(venue INTEGER NOT NULL,singer INTEGER NOT NULL,token TEXT NOT NULL UNIQUE,PRIMARY KEY(venue,singer),FOREIGN KEY(venue,singer) REFERENCES venue_singers(venue,singer) ON DELETE CASCADE)") + try execute("PRAGMA user_version=9") + } + deinit { sqlite3_close(db) } + private func statement(_ sql: String, _ values: [String?]) throws -> OpaquePointer { + var s: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &s, nil) == SQLITE_OK, let s else { throw error() } + for (i,v) in values.enumerated() { + let code = v.map { sqlite3_bind_text(s, Int32(i + 1), $0, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) } ?? sqlite3_bind_null(s, Int32(i + 1)) + if code != SQLITE_OK { sqlite3_finalize(s); throw error() } + } + return s + } + private func error() -> LibraryError { .message(String(cString: sqlite3_errmsg(db))) } + func execute(_ sql: String, _ values: [String?] = []) throws { + let s = try statement(sql, values); defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_DONE else { throw error() } + } + func songs() throws -> [Song] { + let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label,duration,fingerprint FROM songs ORDER BY title COLLATE NOCASE", []); defer { sqlite3_finalize(s) } + var result = [Song]() + while true { + let code = sqlite3_step(s); if code == SQLITE_DONE { break }; guard code == SQLITE_ROW else { throw error() } + result.append(Song(id: sqlite3_column_int64(s,0), title: String(cString: sqlite3_column_text(s,1)), audio: String(cString: sqlite3_column_text(s,2)), cdg: sqlite3_column_text(s,3).map { String(cString:$0) }, rating: Int(sqlite3_column_int(s,4)), plays: Int(sqlite3_column_int(s,5)), artist: text(s,6), album: text(s,7), label: text(s,8),duration:sqlite3_column_double(s,9),fingerprint:text(s,10))) + } + return result + } + @discardableResult func add(title: String,audio: String,cdg: String?,artist: String = "",album: String = "",label: String = "",duration: Double = 0,fingerprint: String = "",preferIncomingFile: Bool = false) throws -> Int64 { + var path = audio,graphics = cdg + if !fingerprint.isEmpty { + let statement = try self.statement("SELECT id,audio,cdg FROM songs WHERE audio=? OR fingerprint=? ORDER BY CASE WHEN audio=? THEN 0 ELSE 1 END,id LIMIT 1",[audio,fingerprint,audio]) + if sqlite3_step(statement) == SQLITE_ROW { + let id = sqlite3_column_int64(statement,0),existing = text(statement,1) + let oldGraphics = sqlite3_column_type(statement,2) == SQLITE_NULL ? nil : text(statement,2) + if existing != audio,URL(fileURLWithPath:existing).pathExtension.lowercased() == URL(fileURLWithPath:audio).pathExtension.lowercased() { + if preferIncomingFile || !FileManager.default.fileExists(atPath:existing) { + sqlite3_finalize(statement) + try execute("UPDATE songs SET audio=?,cdg=? WHERE id=?",[audio,cdg,String(id)]) + } else { + path = existing + if let oldGraphics,FileManager.default.fileExists(atPath:oldGraphics) { graphics = oldGraphics } + sqlite3_finalize(statement) + } + } else { sqlite3_finalize(statement) } + } else { sqlite3_finalize(statement) } + } + try execute("INSERT INTO songs(title,audio,cdg,artist,album,label,duration,fingerprint) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(audio) DO UPDATE SET cdg=COALESCE(excluded.cdg,songs.cdg),artist=CASE WHEN songs.metadata_edited=0 AND excluded.artist!='' THEN excluded.artist ELSE songs.artist END,album=CASE WHEN songs.metadata_edited=0 AND excluded.album!='' THEN excluded.album ELSE songs.album END,label=CASE WHEN songs.metadata_edited=0 AND excluded.label!='' THEN excluded.label ELSE songs.label END,duration=CASE WHEN excluded.duration>0 THEN excluded.duration ELSE songs.duration END,fingerprint=CASE WHEN excluded.fingerprint!='' THEN excluded.fingerprint ELSE songs.fingerprint END",[title,path,graphics,artist,album,label,String(duration),fingerprint]) + let result = try statement("SELECT id FROM songs WHERE audio=?",[path]);defer { sqlite3_finalize(result) } + guard sqlite3_step(result) == SQLITE_ROW else { throw error() };return sqlite3_column_int64(result,0) + } + func rate(_ id: Int64, _ rating: Int) throws { try execute("UPDATE songs SET rating=? WHERE id=?", [String(rating),String(id)]) } + func entities(_ table: String) throws -> [PersonPlace] { + precondition(["singers","venues","playlists"].contains(table)) + let s = try statement("SELECT id,name FROM \(table) ORDER BY name", []); defer { sqlite3_finalize(s) } + var rows = [PersonPlace]() + while sqlite3_step(s) == SQLITE_ROW { rows.append(.init(id: sqlite3_column_int64(s,0), name: String(cString: sqlite3_column_text(s,1)))) } + return rows + } + func addEntity(_ table: String, name: String) throws { + precondition(["singers","venues","playlists"].contains(table)) + let name = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { throw LibraryError.message("Enter a name") } + try execute("INSERT OR IGNORE INTO \(table)(name) VALUES(?)", [name]) + } + func record(_ song: Int64, singer: Int64?, venue: Int64?) throws { + try execute("BEGIN IMMEDIATE") + do { + if let singer { try assignSong(song,singer:singer) } + try execute("INSERT INTO history(song,singer,venue) VALUES(?,?,?)", [String(song),singer.map(String.init),venue.map(String.init)]) + try execute("UPDATE songs SET plays=plays+1 WHERE id=?", [String(song)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK"); throw error } + } + + private func text(_ s: OpaquePointer, _ index: Int32) -> String { sqlite3_column_text(s,index).map { String(cString:$0) } ?? "" } + private func schemaVersion() throws -> Int { + let s = try statement("PRAGMA user_version",[]); defer { sqlite3_finalize(s) } + return sqlite3_step(s) == SQLITE_ROW ? Int(sqlite3_column_int(s,0)) : 0 + } + private func columns(_ table: String) throws -> Set { + let s = try statement("PRAGMA table_info(\(table))", []); defer { sqlite3_finalize(s) } + var result = Set(); while sqlite3_step(s) == SQLITE_ROW { result.insert(text(s,1)) }; return result + } + func members(of venue: Int64) throws -> [PersonPlace] { + let s = try statement("SELECT singers.id,singers.name FROM singers JOIN venue_singers ON singers.id=venue_singers.singer WHERE venue=? ORDER BY name", [String(venue)]); defer { sqlite3_finalize(s) } + var result = [PersonPlace](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1))) }; return result + } + func setMembership(singer: Int64, venue: Int64, included: Bool) throws { + try execute(included ? "INSERT OR IGNORE INTO venue_singers(venue,singer) VALUES(?,?)" : "DELETE FROM venue_singers WHERE venue=? AND singer=?", [String(venue),String(singer)]) + if !included { try execute("DELETE FROM queue WHERE venue=? AND singer=?",[String(venue),String(singer)]) } + } + func currentVenue() throws -> Int64? { + let s = try statement("SELECT venues.id FROM venues JOIN settings ON CAST(settings.value AS INTEGER)=venues.id WHERE settings.key='currentVenue'", []); defer { sqlite3_finalize(s) } + return sqlite3_step(s) == SQLITE_ROW ? sqlite3_column_int64(s,0) : nil + } + func selectVenue(_ id: Int64?) throws { + if let id { + guard try entities("venues").contains(where: { $0.id == id }) else { throw LibraryError.message("Venue no longer exists") } + } + try execute("INSERT INTO settings(key,value) VALUES('currentVenue',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",[id.map(String.init)]) + } + func rename(_ table: String, id: Int64, name: String) throws { + precondition(["singers","venues","playlists"].contains(table)) + let name = name.trimmingCharacters(in:.whitespacesAndNewlines) + guard !name.isEmpty else { throw LibraryError.message("Enter a name") } + try execute("UPDATE \(table) SET name=? WHERE id=?",[name,String(id)]) + } + func deleteEntity(_ table: String, id: Int64) throws { + precondition(["singers","venues","playlists"].contains(table)) + try execute("BEGIN IMMEDIATE") + do { + if table == "singers" { try execute("UPDATE history SET singer=NULL WHERE singer=?",[String(id)]) } + if table == "venues" { + try execute("UPDATE history SET venue=NULL WHERE venue=?",[String(id)]) + try execute("DELETE FROM settings WHERE key='currentVenue' AND value=?",[String(id)]) + } + try execute("DELETE FROM \(table) WHERE id=?",[String(id)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK"); throw error } + } + func preference(song: Int64, singer: Int64) throws -> SongPreference { + let s = try statement("SELECT pitch,tempo,rating FROM preferences WHERE song=? AND singer=?",[String(song),String(singer)]); defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_ROW else { return try defaultPreference(song:song) } + return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:Int(sqlite3_column_int(s,2))) + } + func savePreference(song: Int64, singer: Int64, value: SongPreference) throws { + guard value.pitch.isFinite,value.tempo.isFinite,(-12...12).contains(value.pitch),(0.5...1.5).contains(value.tempo),(0...5).contains(value.rating) else { throw LibraryError.message("Invalid singer settings") } + try execute("INSERT INTO preferences(song,singer,pitch,tempo,rating) VALUES(?,?,?,?,?) ON CONFLICT(song,singer) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo,rating=excluded.rating",[String(song),String(singer),String(value.pitch),String(value.tempo),String(value.rating)]) + } + func history(venue: Int64?, singer: Int64? = nil) throws -> [Performance] { + let s = try statement("SELECT history.id,COALESCE(songs.title,'Deleted song'),COALESCE(singers.name,'—'),COALESCE(venues.name,'—'),played,history.singer,history.song FROM history LEFT JOIN songs ON songs.id=history.song LEFT JOIN singers ON singers.id=history.singer LEFT JOIN venues ON venues.id=history.venue WHERE (? IS NULL OR history.venue=?) AND (? IS NULL OR history.singer=?) ORDER BY history.id DESC",[venue.map(String.init),venue.map(String.init),singer.map(String.init),singer.map(String.init)]); defer { sqlite3_finalize(s) } + var result = [Performance](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),song:text(s,1),singer:text(s,2),venue:text(s,3),date:text(s,4),singerID:sqlite3_column_type(s,5) == SQLITE_NULL ? nil : sqlite3_column_int64(s,5),songID:sqlite3_column_type(s,6) == SQLITE_NULL ? nil : sqlite3_column_int64(s,6))) }; return result + } + func enqueue(song: Int64, singer: Int64?, venue: Int64?) throws { + guard let venue else { throw LibraryError.message("Select a current venue before adding to its queue") } + if let singer, try !members(of:venue).contains(where: { $0.id == singer }) { throw LibraryError.message("Add this singer to the current venue first") } + try execute("BEGIN IMMEDIATE") + do { + if let singer { + try assignSong(song,singer:singer) + try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_tonight WHERE venue=? AND singer=?))",[String(venue),String(singer),String(song),String(venue),String(singer)]) + } + try execute("INSERT INTO queue(venue,song,singer,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?))",[String(venue),String(song),singer.map(String.init),String(venue)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK");throw error } + } + func queue(venue: Int64?) throws -> [QueueEntry] { + guard let venue else { return [] } + let s = try statement("SELECT queue.id,queue.song,queue.singer,songs.title,COALESCE(singers.name,'No singer') FROM queue JOIN songs ON songs.id=queue.song LEFT JOIN singers ON singers.id=queue.singer WHERE queue.venue=? ORDER BY queue.position,queue.id",[String(venue)]); defer { sqlite3_finalize(s) } + var result = [QueueEntry](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),songID:sqlite3_column_int64(s,1),singerID:sqlite3_column_type(s,2) == SQLITE_NULL ? nil : sqlite3_column_int64(s,2),title:text(s,3),singer:text(s,4))) }; return result + } + func removeQueue(_ id: Int64) throws { try execute("DELETE FROM queue WHERE id=?",[String(id)]) } + func moveQueue(_ id: Int64, venue: Int64, by offset: Int) throws { + var rows = try queue(venue:venue) + guard let index = rows.firstIndex(where: { $0.id == id }), rows.indices.contains(index+offset) else { return } + rows.swapAt(index,index+offset) + try execute("BEGIN IMMEDIATE") + do { for (i,row) in rows.enumerated() { try execute("UPDATE queue SET position=? WHERE id=?",[String(i),String(row.id)]) }; try execute("COMMIT") } + catch { try? execute("ROLLBACK"); throw error } + } + func addToPlaylist(_ playlist: Int64, song: Int64) throws { + try execute("INSERT OR IGNORE INTO playlist_songs(playlist,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM playlist_songs WHERE playlist=?))",[String(playlist),String(song),String(playlist)]) + } + func playlistSongIDs(_ playlist: Int64) throws -> [Int64] { + let s = try statement("SELECT song FROM playlist_songs WHERE playlist=? ORDER BY position",[String(playlist)]); defer { sqlite3_finalize(s) } + var ids = [Int64](); while sqlite3_step(s) == SQLITE_ROW { ids.append(sqlite3_column_int64(s,0)) }; return ids + } + func removeFromPlaylist(_ playlist: Int64, song: Int64) throws { try execute("DELETE FROM playlist_songs WHERE playlist=? AND song=?",[String(playlist),String(song)]) } + func assignSong(_ song: Int64, singer: Int64) throws { + try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_songs WHERE singer=?))",[String(singer),String(song),String(singer)]) + } + func removeSingerSong(_ song: Int64, singer: Int64) throws { + try execute("DELETE FROM singer_songs WHERE singer=? AND song=?",[String(singer),String(song)]) + } + func singerSongIDs() throws -> [Int64:[Int64]] { + let s = try statement("SELECT singer,song FROM singer_songs ORDER BY singer,position,song",[]); defer { sqlite3_finalize(s) } + var result = [Int64:[Int64]]() + while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].append(sqlite3_column_int64(s,1)) } + return result + } + func defaultPreference(song: Int64) throws -> SongPreference { + let s = try statement("SELECT pitch,tempo FROM song_settings WHERE song=?",[String(song)]);defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_ROW else { return .init() } + return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:0) + } + func saveDefaultPreference(song: Int64, value: SongPreference) throws { + try execute("INSERT INTO song_settings(song,pitch,tempo) VALUES(?,?,?) ON CONFLICT(song) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo",[String(song),String(value.pitch),String(value.tempo)]) + } + func updateDuration(_ song: Int64, seconds: Double) throws { + guard seconds.isFinite && seconds >= 0 else { return } + try execute("UPDATE songs SET duration=? WHERE id=?",[String(seconds),String(song)]) + } + func setFavourite(song: Int64, singer: Int64, included: Bool) throws { + if included { try assignSong(song,singer:singer) } + try execute("UPDATE singer_songs SET favourite=? WHERE singer=? AND song=?",[included ? "1" : "0",String(singer),String(song)]) + } + func favouriteSongIDs() throws -> [Int64:Set] { + let s = try statement("SELECT singer,song FROM singer_songs WHERE favourite=1",[]);defer { sqlite3_finalize(s) } + var result = [Int64:Set]() + while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].insert(sqlite3_column_int64(s,1)) };return result + } + func stores() throws -> [MusicStore] { + let s = try statement("SELECT id,name,url,search_template FROM music_stores ORDER BY name",[]);defer { sqlite3_finalize(s) } + var result = [MusicStore]() + while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1),url:text(s,2),searchTemplate:text(s,3))) };return result + } + func saveStore(_ store: MusicStore) throws { + let name = store.name.trimmingCharacters(in:.whitespacesAndNewlines),url = store.url.trimmingCharacters(in:.whitespacesAndNewlines),search = store.searchTemplate.trimmingCharacters(in:.whitespacesAndNewlines) + guard !name.isEmpty else { throw LibraryError.message("Enter a store name") } + let cleaned = MusicStore(id:store.id,name:name,url:url,searchTemplate:search) + _ = try cleaned.destination() + if !search.isEmpty { + guard search.contains("{query}") else { throw LibraryError.message("Search URL must contain {query}") } + _ = try cleaned.destination(query:"test") + } + if store.id == 0 { try execute("INSERT INTO music_stores(name,url,search_template) VALUES(?,?,?)",[name,url,search]) } + else { try execute("UPDATE music_stores SET name=?,url=?,search_template=? WHERE id=?",[name,url,search,String(store.id)]) } + } + func deleteStore(_ id: Int64) throws { try execute("DELETE FROM music_stores WHERE id=?",[String(id)]) } + func updateMetadata(song: Int64,title: String,artist: String,album: String,label: String,rating: Int) throws { + let title = title.trimmingCharacters(in:.whitespacesAndNewlines) + guard !title.isEmpty else { throw LibraryError.message("Song title cannot be empty") } + try execute("UPDATE songs SET title=?,artist=?,album=?,label=?,rating=?,metadata_edited=1 WHERE id=?",[title,artist.trimmingCharacters(in:.whitespacesAndNewlines),album.trimmingCharacters(in:.whitespacesAndNewlines),label.trimmingCharacters(in:.whitespacesAndNewlines),String(rating),String(song)]) + guard sqlite3_changes(db) == 1 else { throw LibraryError.message("Song no longer exists") } + } + func tonightSongIDs(venue: Int64?) throws -> [Int64:[Int64]] { + guard let venue else { return [:] } + let s = try statement("SELECT singer,song FROM singer_tonight WHERE venue=? ORDER BY position,song",[String(venue)]);defer { sqlite3_finalize(s) } + var result = [Int64:[Int64]]() + while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].append(sqlite3_column_int64(s,1)) };return result + } + func addTonight(song: Int64,singer: Int64,venue: Int64,transaction: Bool = true) throws { + guard try members(of:venue).contains(where:{ $0.id == singer }) else { throw LibraryError.message("This singer is not a member of the current venue") } + if transaction { try execute("BEGIN IMMEDIATE") } + do { + try assignSong(song,singer:singer) + try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_tonight WHERE venue=? AND singer=?))",[String(venue),String(singer),String(song),String(venue),String(singer)]) + try execute("INSERT INTO queue(venue,song,singer,position) SELECT ?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?) WHERE NOT EXISTS(SELECT 1 FROM queue WHERE venue=? AND singer=? AND song=?)",[String(venue),String(song),String(singer),String(venue),String(venue),String(singer),String(song)]) + if transaction { try execute("COMMIT") } + } catch { if transaction { try? execute("ROLLBACK") };throw error } + } + func removeTonight(song: Int64,singer: Int64,venue: Int64) throws { + try execute("BEGIN IMMEDIATE") + do { + try execute("DELETE FROM singer_tonight WHERE venue=? AND singer=? AND song=?",[String(venue),String(singer),String(song)]) + try execute("DELETE FROM queue WHERE venue=? AND singer=? AND song=?",[String(venue),String(singer),String(song)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK");throw error } + } + /// Atomically adds/copies or reorders a request, retaining other singers' queue slots. + func placeTonight(song: Int64,singer: Int64,venue: Int64,before: Int64?,addIfNeeded: Bool) throws { + guard try members(of:venue).contains(where:{ $0.id == singer }) else { throw LibraryError.message("This singer is not in the target venue") } + try execute("BEGIN IMMEDIATE") + do { + var ids = try tonightSongIDs(venue:venue)[singer] ?? [] + if let before, !ids.contains(before) { throw LibraryError.message("The drop target changed. Please drop again.") } + if !ids.contains(song) && !addIfNeeded { throw LibraryError.message("This Tonight request no longer exists") } + if addIfNeeded { + try assignSong(song,singer:singer) + try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) VALUES(?,?,?,0)",[String(venue),String(singer),String(song)]) + try execute("INSERT INTO queue(venue,song,singer,position) SELECT ?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?) WHERE NOT EXISTS(SELECT 1 FROM queue WHERE venue=? AND singer=? AND song=?)",[String(venue),String(song),String(singer),String(venue),String(venue),String(singer),String(song)]) + } + if before != song { + ids.removeAll { $0 == song } + if let before,let index = ids.firstIndex(of:before) { ids.insert(song,at:index) } else { ids.append(song) } + } + for (index,id) in ids.enumerated() { try execute("UPDATE singer_tonight SET position=? WHERE venue=? AND singer=? AND song=?",[String(index),String(venue),String(singer),String(id)]) } + let all = try queue(venue:venue) + let slots = all.indices.filter { all[$0].singerID == singer } + let rank = Dictionary(uniqueKeysWithValues:ids.enumerated().map { ($0.element,$0.offset) }) + let requests = slots.enumerated().map { (original:$0.offset,row:all[$0.element]) }.sorted { + let a = rank[$0.row.songID] ?? Int.max,b = rank[$1.row.songID] ?? Int.max + return a == b ? $0.original < $1.original : a < b + } + var ordered = all + for (slot,request) in zip(slots,requests) { ordered[slot] = request.row } + for (index,row) in ordered.enumerated() { try execute("UPDATE queue SET position=? WHERE id=?",[String(index),String(row.id)]) } + try execute("COMMIT") + } catch { try? execute("ROLLBACK");throw error } + } + func mediaLocations() throws -> [MediaLocation] { + let s = try statement("SELECT media_sources.id,name,media_sources.path,bookmark,external,last_scan,last_error,(SELECT COUNT(DISTINCT song) FROM source_songs WHERE source=media_sources.id) FROM media_sources ORDER BY name",[]);defer { sqlite3_finalize(s) } + var result = [MediaLocation]() + while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1),path:text(s,2),bookmark:Data(base64Encoded:text(s,3)),external:sqlite3_column_int(s,4) != 0,lastScan:text(s,5),lastError:text(s,6),songCount:Int(sqlite3_column_int(s,7)))) };return result + } + func saveMediaLocation(name: String,url: URL,bookmark: Data?,external: Bool) throws -> Int64 { + try execute("INSERT INTO media_sources(name,path,bookmark,external) VALUES(?,?,?,?) ON CONFLICT(path) DO UPDATE SET name=excluded.name,bookmark=COALESCE(excluded.bookmark,media_sources.bookmark),external=excluded.external",[name,url.standardizedFileURL.path,bookmark?.base64EncodedString(),external ? "1" : "0"]) + let s = try statement("SELECT id FROM media_sources WHERE path=?",[url.standardizedFileURL.path]);defer { sqlite3_finalize(s) };guard sqlite3_step(s) == SQLITE_ROW else { throw error() };return sqlite3_column_int64(s,0) + } + func removeMediaLocation(_ id: Int64) throws { try execute("DELETE FROM media_sources WHERE id=?",[String(id)]) } + func sourceSnapshot(_ source: Int64) throws -> [String:IndexedSourceFile] { + let s = try statement("SELECT source_files.path,size,modified,song FROM source_files LEFT JOIN source_songs ON source_songs.source=source_files.source AND source_songs.path=source_files.path WHERE source_files.source=?",[String(source)]);defer { sqlite3_finalize(s) } + var result = [String:IndexedSourceFile]() + while sqlite3_step(s) == SQLITE_ROW { + let path = text(s,0) + if result[path] == nil { result[path] = .init(size:sqlite3_column_int64(s,1),modified:sqlite3_column_double(s,2),songs:[]) } + if sqlite3_column_type(s,3) != SQLITE_NULL { result[path]?.songs.append(sqlite3_column_int64(s,3)) } + };return result + } + func recordSourceFile(source: Int64,path: String,size: Int64,modified: Double,songs: [Int64]) throws { + try execute("BEGIN IMMEDIATE") + do { + try execute("INSERT INTO source_files(source,path,size,modified) VALUES(?,?,?,?) ON CONFLICT(source,path) DO UPDATE SET size=excluded.size,modified=excluded.modified",[String(source),path,String(size),String(modified)]) + try execute("DELETE FROM source_songs WHERE source=? AND path=?",[String(source),path]) + for song in Set(songs) { try execute("INSERT INTO source_songs(source,path,song) VALUES(?,?,?)",[String(source),path,String(song)]) } + try execute("COMMIT") + } catch { try? execute("ROLLBACK");throw error } + } + func finishSourceScan(_ source: Int64,path: String,message: String) throws { + try execute("UPDATE media_sources SET path=?,last_scan=?,last_error=? WHERE id=?",[path,ISO8601DateFormatter().string(from:Date()),message,String(source)]) + } + func song(_ id: Int64) throws -> Song? { + let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label,duration,fingerprint FROM songs WHERE id=?",[String(id)]);defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_ROW else { return nil } + return .init(id:sqlite3_column_int64(s,0),title:text(s,1),audio:text(s,2),cdg:sqlite3_column_type(s,3) == SQLITE_NULL ? nil : text(s,3),rating:Int(sqlite3_column_int(s,4)),plays:Int(sqlite3_column_int(s,5)),artist:text(s,6),album:text(s,7),label:text(s,8),duration:sqlite3_column_double(s,9),fingerprint:text(s,10)) + } +} + +extension Library { + func setting(_ key: String) throws -> String? { + let s = try statement("SELECT value FROM settings WHERE key=?",[key]);defer { sqlite3_finalize(s) } + return sqlite3_step(s) == SQLITE_ROW ? text(s,0) : nil + } + func setSetting(_ key: String,_ value: String?) throws { + try execute("INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",[key,value]) + } + func rotation(_ venue: Int64) throws -> [RotationSinger] { + let s = try statement("SELECT singer_rotation.singer,singers.name,singer_rotation.here FROM singer_rotation JOIN singers ON singers.id=singer_rotation.singer WHERE venue=? ORDER BY position,singer_rotation.singer",[String(venue)]);defer { sqlite3_finalize(s) } + var rows = [RotationSinger]() + while sqlite3_step(s) == SQLITE_ROW { rows.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1),here:sqlite3_column_int(s,2) != 0)) };return rows + } + func markHere(singer: Int64,venue: Int64,here: Bool) throws { + guard try members(of:venue).contains(where:{$0.id == singer}) else { throw LibraryError.message("Singer is not at this venue") } + try execute("INSERT INTO singer_rotation(venue,singer,position,here) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_rotation WHERE venue=?),?) ON CONFLICT(venue,singer) DO UPDATE SET here=excluded.here,position=CASE WHEN singer_rotation.here=0 AND excluded.here=1 THEN excluded.position ELSE singer_rotation.position END",[String(venue),String(singer),String(venue),here ? "1" : "0"]) + } + func moveRotation(singer: Int64,venue: Int64,by offset: Int) throws { + var rows = try rotation(venue);guard let i = rows.firstIndex(where:{$0.id == singer}),rows.indices.contains(i+offset) else { return };rows.swapAt(i,i+offset) + try execute("BEGIN IMMEDIATE") + do { for (i,row) in rows.enumerated() { try execute("UPDATE singer_rotation SET position=? WHERE venue=? AND singer=?",[String(i),String(venue),String(row.id)]) };try execute("COMMIT") } catch { try? execute("ROLLBACK");throw error } + } + func assignQueue(_ entry: Int64,singer: Int64?,venue: Int64) throws { + guard let row = try queue(venue:venue).first(where:{$0.id == entry}) else { throw LibraryError.message("Queue entry changed") } + if let singer { guard try members(of:venue).contains(where:{$0.id == singer}) else { throw LibraryError.message("Singer is not at this venue") };try assignSong(row.songID,singer:singer) } + try execute("UPDATE queue SET singer=? WHERE id=? AND venue=?",[singer.map(String.init),String(entry),String(venue)]) + } + func reorderPlaylist(_ playlist: Int64,song: Int64,by offset: Int) throws { + var ids = try playlistSongIDs(playlist);guard let i = ids.firstIndex(of:song),ids.indices.contains(i+offset) else { return };ids.swapAt(i,i+offset) + try execute("BEGIN IMMEDIATE") + do { for (i,id) in ids.enumerated() { try execute("UPDATE playlist_songs SET position=? WHERE playlist=? AND song=?",[String(i),String(playlist),String(id)]) };try execute("COMMIT") } catch { try? execute("ROLLBACK");throw error } + } + func batchMetadata(_ ids: Set,patch: MetadataPatch) throws { + let updates = try ids.map { id -> Song in guard let song = try song(id) else { throw LibraryError.message("A selected song no longer exists") };return try patch.applying(to:song) } + try execute("BEGIN IMMEDIATE") + do { for song in updates { try updateMetadata(song:song.id,title:song.title,artist:song.artist,album:song.album,label:song.label,rating:song.rating) };try execute("COMMIT") } catch { try? execute("ROLLBACK");throw error } + } + func screens() throws -> [ShowScreen] { + let s = try statement("SELECT songs.id,songs.title,show_screens.body,show_screens.footer,show_screens.tint,songs.duration FROM show_screens JOIN songs ON songs.id=show_screens.song ORDER BY songs.title COLLATE NOCASE",[]);defer { sqlite3_finalize(s) } + var rows = [ShowScreen]();while sqlite3_step(s) == SQLITE_ROW { rows.append(.init(id:sqlite3_column_int64(s,0),title:text(s,1),body:text(s,2),footer:text(s,3),tint:text(s,4),seconds:sqlite3_column_double(s,5))) };return rows + } + @discardableResult func saveScreen(_ screen: ShowScreen) throws -> Int64 { + guard !screen.title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty,(3...300).contains(screen.seconds),screen.body.count <= 4000,screen.footer.count <= 500 else { throw LibraryError.message("Give the slide a title and a duration from 3 to 300 seconds") } + try execute("BEGIN IMMEDIATE") + do { + let id: Int64 + if screen.id == 0 { id = try add(title:screen.title,audio:"showscreen://"+UUID().uuidString,cdg:nil,duration:screen.seconds) } + else { guard try screens().contains(where:{$0.id == screen.id}) else { throw LibraryError.message("Slide no longer exists") };id = screen.id;try execute("UPDATE songs SET title=?,duration=? WHERE id=?",[screen.title,String(screen.seconds),String(id)]) } + try execute("INSERT INTO show_screens(song,body,footer,tint) VALUES(?,?,?,?) ON CONFLICT(song) DO UPDATE SET body=excluded.body,footer=excluded.footer,tint=excluded.tint",[String(id),screen.body,screen.footer,screen.tint]);try execute("COMMIT");return id + } catch { try? execute("ROLLBACK");throw error } + } + func singerToken(singer: Int64,venue: Int64,renew: Bool = false) throws -> String { + guard try members(of:venue).contains(where:{$0.id == singer}) else { throw LibraryError.message("Singer is not at this venue") } + if !renew { + let s = try statement("SELECT token FROM singer_access WHERE venue=? AND singer=?",[String(venue),String(singer)]);defer { sqlite3_finalize(s) } + if sqlite3_step(s) == SQLITE_ROW { return text(s,0) } + } + let token = UUID().uuidString.replacingOccurrences(of:"-",with:"")+UUID().uuidString.replacingOccurrences(of:"-",with:"") + try execute("INSERT INTO singer_access(venue,singer,token) VALUES(?,?,?) ON CONFLICT(venue,singer) DO UPDATE SET token=excluded.token",[String(venue),String(singer),token]);return token + } + func singerForToken(_ token: String,venue: Int64) throws -> Int64? { + guard token.count == 64 else { return nil } + let s = try statement("SELECT singer FROM singer_access WHERE venue=? AND token=?",[String(venue),token]);defer { sqlite3_finalize(s) } + return sqlite3_step(s) == SQLITE_ROW ? sqlite3_column_int64(s,0) : nil + } + func revokeSinger(singer: Int64,venue: Int64) throws { try execute("DELETE FROM singer_access WHERE singer=? AND venue=?",[String(singer),String(venue)]) } +} + +extension Library { + /// One bulk read replaces per-row database queries during navigation and drawing. + func tuningSnapshot(singer: Int64?) throws -> [Int64:SongPreference] { + let defaults = try statement("SELECT song,pitch,tempo FROM song_settings",[]);defer { sqlite3_finalize(defaults) } + var result = [Int64:SongPreference]() + while sqlite3_step(defaults) == SQLITE_ROW { result[sqlite3_column_int64(defaults,0)] = .init(pitch:sqlite3_column_double(defaults,1),tempo:sqlite3_column_double(defaults,2)) } + if let singer { + let values = try statement("SELECT song,pitch,tempo,rating FROM preferences WHERE singer=?",[String(singer)]);defer { sqlite3_finalize(values) } + while sqlite3_step(values) == SQLITE_ROW { result[sqlite3_column_int64(values,0)] = .init(pitch:sqlite3_column_double(values,1),tempo:sqlite3_column_double(values,2),rating:Int(sqlite3_column_int(values,3))) } + };return result + } + func orderQueueByRoster(venue: Int64) throws { + let roster = try rotation(venue).map(\.id),rows = try queue(venue:venue) + let rank = Dictionary(uniqueKeysWithValues:roster.enumerated().map{($0.element,$0.offset)}) + let ordered = rows.enumerated().sorted { + let a = $0.element.singerID.flatMap{rank[$0]} ?? Int.max + let b = $1.element.singerID.flatMap{rank[$0]} ?? Int.max + return a == b ? $0.offset < $1.offset : a < b + }.map(\.element) + try execute("BEGIN IMMEDIATE") + do { for (index,row) in ordered.enumerated() { try execute("UPDATE queue SET position=? WHERE id=? AND venue=?",[String(index),String(row.id),String(venue)]) };try execute("COMMIT") } + catch { try? execute("ROLLBACK");throw error } + } + func placeRotation(singer: Int64,venue: Int64,before: Int64?) throws { + var ids = try rotation(venue).map(\.id) + guard ids.contains(singer),before == nil || ids.contains(before!) else { throw LibraryError.message("Rotation changed; choose the singer again") } + if before == singer { return };ids.removeAll{$0 == singer};if let before,let i = ids.firstIndex(of:before) { ids.insert(singer,at:i) } else { ids.append(singer) } + try execute("BEGIN IMMEDIATE") + do { for (i,id) in ids.enumerated() { try execute("UPDATE singer_rotation SET position=? WHERE venue=? AND singer=?",[String(i),String(venue),String(id)]) };try execute("COMMIT") } catch { try? execute("ROLLBACK");throw error } + } +} diff --git a/KJamsClone/LibraryScanner.swift b/KJamsClone/LibraryScanner.swift new file mode 100644 index 0000000..10ace0d --- /dev/null +++ b/KJamsClone/LibraryScanner.swift @@ -0,0 +1,81 @@ +import Foundation + +struct SourceScanProgress: Sendable { + var source: Int64 + var files = 0 + var indexed = 0 + var generated = 0 + var cached = 0 + var issues = 0 + var current = "Starting…" + var messages = [String]() + var cancelled = false + var finished = false +} + +/// One utility-priority worker/SQLite connection. Audio stays on the source volume; +/// ZIP members use managed media because temporary extraction cannot be referenced. +enum LibraryScanner { + static func scan(_ source: MediaLocation,database: URL,media: URL,cache: URL,forceWaveforms: Bool = false,cancelled: () -> Bool = { false },progress: (SourceScanProgress) -> Void = { _ in }) throws -> SourceScanProgress { + var root = URL(fileURLWithPath:source.path) + if let bookmark = source.bookmark,!bookmark.isEmpty { + var stale = false + if let resolved = try? URL(resolvingBookmarkData:bookmark,options:[.withSecurityScope,.withoutUI],relativeTo:nil,bookmarkDataIsStale:&stale) { root = resolved } + } + let scoped = root.startAccessingSecurityScopedResource();defer { if scoped { root.stopAccessingSecurityScopedResource() } } + var directory: ObjCBool = false + guard FileManager.default.fileExists(atPath:root.path,isDirectory:&directory),directory.boolValue else { throw LibraryError.message("Drive or folder is unavailable. Reconnect it and scan again.") } + let db = try Library(url:database),importer = Importer(media:media) + let snapshot = try db.sourceSnapshot(source.id) + var songs = Dictionary(uniqueKeysWithValues:try db.songs().map { ($0.id,$0) }) + var state = SourceScanProgress(source:source.id),lastReport = Date.distantPast + var seenSongs = Set() + var enumerationMessages = [String]() + guard let enumerator = FileManager.default.enumerator(at:root,includingPropertiesForKeys:[.isRegularFileKey,.isSymbolicLinkKey],options:[.skipsHiddenFiles,.skipsPackageDescendants],errorHandler:{ url,error in + if enumerationMessages.count < 20 { enumerationMessages.append(url.lastPathComponent+": "+error.localizedDescription) };return true + }) else { throw LibraryError.message("Cannot read this folder") } + for case let file as URL in enumerator { + if cancelled() { state.cancelled = true;break } + guard ["mp3","wav","m4a","mp4","m4v","mov","zip"].contains(file.pathExtension.lowercased()) else { continue } + let values = try? file.resourceValues(forKeys:[.isRegularFileKey,.isSymbolicLinkKey]) + guard values?.isRegularFile == true,values?.isSymbolicLink != true else { continue } + state.files += 1;state.current = file.lastPathComponent + do { + let attributes = try FileManager.default.attributesOfItem(atPath:file.path) + let size = (attributes[.size] as? NSNumber)?.int64Value ?? 0,modified = (attributes[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 + let ids: [Int64] + if let old = snapshot[file.path],old.size == size,old.modified == modified,!old.songs.isEmpty,old.songs.allSatisfy({ id in songs[id].map { FileManager.default.fileExists(atPath:$0.audio) } ?? false }) { + ids = old.songs + } else { + ids = try importer.run([file],library:db,copyMedia:false,cancelled:cancelled) + try db.recordSourceFile(source:source.id,path:file.path,size:size,modified:modified,songs:ids) + // Refresh after deduplication/relocation, without sharing the UI connection. + for id in ids { if let song = try db.song(id) { songs[id] = song } } + } + seenSongs.formUnion(ids);state.indexed = seenSongs.count + for id in ids { + if cancelled() { throw CancellationError() } + guard let song = songs[id] else { continue } + let audio = URL(fileURLWithPath:song.audio) + if !forceWaveforms,(try? WaveformCache.load(audio:audio,directory:cache)) != nil { state.cached += 1 } + else { + let analysis = try AudioWaveform.analyse(url:audio,cancelled:cancelled) + try WaveformCache.save(analysis,audio:audio,directory:cache);state.generated += 1 + } + } + } catch is CancellationError { state.cancelled = true;break } + catch { + state.issues += 1 + if state.messages.count < 100 { state.messages.append(file.lastPathComponent+": "+error.localizedDescription) } + } + if Date().timeIntervalSince(lastReport) >= 0.12 { progress(state);lastReport = Date() } + } + state.issues += enumerationMessages.count;state.messages += enumerationMessages + state.finished = true + state.current = state.cancelled ? "Cancelled — completed work kept" : "Scan complete" + if !FileManager.default.fileExists(atPath:root.path) { state.issues += 1;state.messages.append("The source became unavailable during the scan") } + let message = state.cancelled ? "Cancelled; completed work kept" : state.issues > 0 ? "\(state.issues) file/read issues; see scan details" : "" + try db.finishSourceScan(source.id,path:root.path,message:message) + progress(state);return state + } +} diff --git a/KJamsClone/Metadata.swift b/KJamsClone/Metadata.swift new file mode 100644 index 0000000..9d60a5d --- /dev/null +++ b/KJamsClone/Metadata.swift @@ -0,0 +1,267 @@ +import SwiftUI +import AVFoundation + +struct MetadataPatch { + var title: String?;var artist: String?;var album: String?;var label: String?;var rating: Int? + var replaceField = "title";var find: String?;var replacement = "" + func applying(to original: Song) throws -> Song { + var song = original + if let title { song.title = title };if let artist { song.artist = artist };if let album { song.album = album };if let label { song.label = label };if let rating { song.rating = rating } + if let find,!find.isEmpty { + switch replaceField { + case "title":song.title = song.title.replacingOccurrences(of:find,with:replacement,options:.caseInsensitive) + case "artist":song.artist = song.artist.replacingOccurrences(of:find,with:replacement,options:.caseInsensitive) + case "album":song.album = song.album.replacingOccurrences(of:find,with:replacement,options:.caseInsensitive) + case "label":song.label = song.label.replacingOccurrences(of:find,with:replacement,options:.caseInsensitive) + default:throw LibraryError.message("Unknown metadata field") + } + } + guard !song.title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty,(0...5).contains(song.rating) else { throw LibraryError.message("Every song needs a title and a rating from 0 to 5") };return song + } +} +enum SongSearch { + static func matches(_ song: Song,query: String) -> Bool { + if query.isEmpty { return true } + if query.allSatisfy(\.isNumber),let id = Int64(query) { return song.id == id } + var tokens = [String](),current = "",quoted = false + for char in query { + if char == "\"" { quoted.toggle() } + else if !quoted && (char.isWhitespace || char == ",") { if !current.isEmpty { tokens.append(current);current = "" } } + else { current.append(char) } + } + if !current.isEmpty { tokens.append(current) } + return tokens.allSatisfy { token in + let escaped = token.replacingOccurrences(of:"::",with:"\u{1f}") + let parts = escaped.split(separator:":",maxSplits:1,omittingEmptySubsequences:false).map{String($0).replacingOccurrences(of:"\u{1f}",with:":")} + let fields: [String],term: String + if parts.count == 2 { + term = parts[1] + switch parts[0].lowercased() { + case "title","song","s":fields = [song.title] + case "artist","a":fields = [song.artist] + case "album":fields = [song.album] + case "label":fields = [song.label] + case "id":fields = [String(song.id)] + case "rating":fields = [String(song.rating)] + default:fields = [song.title,song.artist,song.album,song.label];return fields.contains{$0.localizedCaseInsensitiveContains(token)} + } + } else { fields = [song.title,song.artist,song.album,song.label];term = parts[0] } + if term.hasPrefix("=") { return fields.contains{$0.compare(String(term.dropFirst()),options:[.caseInsensitive,.diacriticInsensitive]) == .orderedSame} } + return fields.contains{$0.localizedCaseInsensitiveContains(term)} + } + } +} +struct MetadataCandidate: Identifiable, Decodable { + var trackId: Int64;var trackName: String;var artistName: String;var collectionName: String?;var trackViewUrl: String? + var id: Int64 { trackId } +} +actor OnlineMetadata { + static let shared = OnlineMetadata() + private var cache = [String:[MetadataCandidate]]() + private var nextRequest = Date.distantPast + func search(_ text: String) async throws -> [MetadataCandidate] { + let query = text.trimmingCharacters(in:.whitespacesAndNewlines) + guard query.count >= 2,query.count <= 300 else { throw LibraryError.message("Search with 2–300 characters") } + if let cached = cache[query] { return cached } + let slot = max(Date(),nextRequest);nextRequest = slot.addingTimeInterval(3.1) + let delay = slot.timeIntervalSinceNow;if delay > 0 { try await Task.sleep(nanoseconds:UInt64(delay*1_000_000_000)) };try Task.checkCancellation() + var url = URLComponents(string:"https://itunes.apple.com/search")! + url.queryItems = [.init(name:"term",value:query),.init(name:"media",value:"music"),.init(name:"entity",value:"song"),.init(name:"country",value:"GB"),.init(name:"limit",value:"25")] + var request = URLRequest(url:url.url!);request.timeoutInterval = 20;request.setValue("KJMixPro/0.5.0",forHTTPHeaderField:"User-Agent") + let (data,response) = try await URLSession.shared.data(for:request) + guard let response = response as? HTTPURLResponse,response.statusCode == 200,data.count < 2_000_000 else { throw LibraryError.message("Metadata service is unavailable. Please try later.") } + struct Results: Decodable { var results: [MetadataCandidate] } + let result = try JSONDecoder().decode(Results.self,from:data).results + cache[query] = result;return result + } +} + +/// Keeps MP3 audio bytes and unrelated supported ID3 frames. Unsupported tag layouts fail safely. +enum FileTagWriter { + static func write(_ song: Song,backupDirectory: URL) async throws { + let original = URL(fileURLWithPath:song.audio),ext = original.pathExtension.lowercased() + guard ["mp3","m4a","mp4","m4v","mov"].contains(ext),!song.audio.hasPrefix("showscreen:") else { throw LibraryError.message("File tag writing supports MP3, M4A and MP4/MOV; other formats keep database metadata") } + try FileManager.default.createDirectory(at:backupDirectory,withIntermediateDirectories:true) + let backup = backupDirectory.appendingPathComponent(UUID().uuidString+"-"+original.lastPathComponent) + try FileManager.default.copyItem(at:original,to:backup) + let temp = original.deletingLastPathComponent().appendingPathComponent(".kjmix-tags-"+UUID().uuidString+"."+ext) + defer { try? FileManager.default.removeItem(at:temp) } + if ext == "mp3" { try await Task.detached(priority:.utility) { try writeMP3(song,original:original,destination:temp) }.value } + else { + let asset = AVURLAsset(url:original) + guard let exporter = AVAssetExportSession(asset:asset,presetName:AVAssetExportPresetPassthrough) else { throw LibraryError.message("This file cannot be tagged without re-encoding") } + let type: AVFileType = ext == "m4a" ? .m4a : ext == "mov" ? .mov : .mp4 + guard exporter.supportedFileTypes.contains(type) else { throw LibraryError.message("This container cannot preserve its media during tag export") } + exporter.outputURL = temp;exporter.outputFileType = type + let keys: [AVMetadataKey] = [.commonKeyTitle,.commonKeyArtist,.commonKeyAlbumName,.commonKeyPublisher] + var items = asset.metadata.filter{!keys.contains($0.commonKey ?? AVMetadataKey(rawValue:""))} + for (identifier,value) in [(AVMetadataIdentifier.commonIdentifierTitle,song.title),(.commonIdentifierArtist,song.artist),(.commonIdentifierAlbumName,song.album),(.commonIdentifierPublisher,song.label)] { + let item = AVMutableMetadataItem();item.identifier = identifier;item.value = value as NSString;items.append(item) + } + exporter.metadata = items + await withCheckedContinuation { continuation in exporter.exportAsynchronously { continuation.resume() } } + guard exporter.status == .completed else { throw exporter.error ?? LibraryError.message("Tag export failed; original preserved") } + } + _ = try FileManager.default.replaceItemAt(original,withItemAt:temp) + } + static func writeMP3(_ song: Song,original: URL,destination: URL) throws { + let input = try FileHandle(forReadingFrom:original);defer { try? input.close() } + let header = try input.read(upToCount:10) ?? Data() + var oldSize = 0,version: UInt8 = 4,frames = Data() + let replaced: Set = ["TIT2","TPE1","TALB","TPUB"] + if header.count == 10,String(decoding:header.prefix(3),as:UTF8.self) == "ID3" { + version = header[3] + guard [3,4].contains(version),header[5] == 0,header[6...9].allSatisfy({$0 < 128}) else { throw LibraryError.message("Unsupported ID3 layout; original preserved") } + oldSize = header[6...9].reduce(0){($0<<7)|Int($1)} + guard oldSize <= 16_777_216 else { throw LibraryError.message("ID3 tag is too large") } + let tag = try input.read(upToCount:oldSize) ?? Data();guard tag.count == oldSize else { throw LibraryError.message("Truncated ID3 tag") } + var i = 0 + while i+10 <= tag.count,tag[i] != 0 { + let name = String(decoding:tag[i..= 0,i+10+size <= tag.count else { throw LibraryError.message("Truncated ID3 frame") } + if !replaced.contains(name) { frames.append(tag[i.. Data { let shift = sync ? 7 : 8,mask = sync ? 127 : 255;return Data((0..<4).reversed().map{UInt8((value >> ($0*shift)) & mask)}) } + for (name,text) in [("TIT2",song.title),("TPE1",song.artist),("TALB",song.album),("TPUB",song.label)] { + let payload: Data = version == 4 ? Data([3])+Data(text.utf8) : Data([1])+((text.data(using:.utf16)) ?? Data()) + frames.append(Data(name.utf8));frames.append(sizeData(payload.count,sync:version == 4));frames.append(Data([0,0]));frames.append(payload) + } + guard FileManager.default.createFile(atPath:destination.path,contents:nil) else { throw LibraryError.message("Cannot create tagged file") } + let output = try FileHandle(forWritingTo:destination);defer { try? output.close() } + try output.write(contentsOf:Data("ID3".utf8)+Data([version,0,0])+sizeData(frames.count,sync:true)+frames) + try input.seek(toOffset:UInt64(oldSize > 0 || (header.count >= 3 && String(decoding:header.prefix(3),as:UTF8.self) == "ID3") ? oldSize+10 : 0)) + while let data = try input.read(upToCount:1_048_576),!data.isEmpty { try Task.checkCancellation();try output.write(contentsOf:data) } + } +} + +struct BatchMetadataSheet: View { + @ObservedObject var model: AppModel + let ids: Set + @State private var patch = MetadataPatch() + @State private var fields = Set() + @State private var title = "" + @State private var artist = "" + @State private var album = "" + @State private var label = "" + @State private var find = "" + @State private var replacement = "" + @State private var field = "title" + @State private var rating = 0 + @State private var error: String? + @Environment(\.dismiss) private var dismiss + private var effective: MetadataPatch { .init(title:fields.contains("title") ? title : nil,artist:fields.contains("artist") ? artist : nil,album:fields.contains("album") ? album : nil,label:fields.contains("label") ? label : nil,rating:fields.contains("rating") ? rating : nil,replaceField:field,find:find.isEmpty ? nil : find,replacement:replacement) } + var body: some View { + VStack(alignment:.leading,spacing:16) { + Text("Edit \(ids.count) Songs").font(.title2.bold()) + Text("Only checked fields change. Unchecked fields and singer settings remain unchanged. Changes update your library; use Write File Tags separately to update media files.").font(.callout).foregroundStyle(.secondary) + fieldRow("Title",key:"title",text:$title);fieldRow("Artist",key:"artist",text:$artist);fieldRow("Album",key:"album",text:$album);fieldRow("Label",key:"label",text:$label) + HStack { Toggle("Rating",isOn:binding("rating")).frame(width:95);Picker("Rating",selection:$rating) { ForEach(0...5,id:\.self) { Text("\($0) stars").tag($0) } }.labelsHidden().disabled(!fields.contains("rating")) } + Divider() + HStack { Text("Find & replace");Picker("Field",selection:$field) { Text("Title").tag("title");Text("Artist").tag("artist");Text("Album").tag("album");Text("Label").tag("label") }.labelsHidden() } + HStack { TextField("Find (case insensitive)",text:$find);TextField("Replace with",text:$replacement) } + VStack(alignment:.leading,spacing:5) { + Text("Preview").font(.headline) + ForEach(model.songs.filter{ids.contains($0.id)}.prefix(4)) { song in + let next = try? effective.applying(to:song) + Text(next.map{"\(song.title) → \($0.title) · \($0.artist) · \($0.album)"} ?? "Invalid result: a title cannot be empty").font(.caption).lineLimit(2) + } + }.padding(12).frame(maxWidth:.infinity,alignment:.leading).background(Color.primary.opacity(0.05),in:RoundedRectangle(cornerRadius:8)) + if let error { Text(error).foregroundStyle(.red) } + HStack { Button("Cancel") { dismiss() };Spacer();Button("Apply to \(ids.count) Songs") { do { try model.batchMetadata(ids,patch:effective);dismiss() } catch { self.error = error.localizedDescription } }.keyboardShortcut(.defaultAction).disabled(fields.isEmpty && find.isEmpty) } + }.padding(24).frame(width:590).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).textFieldStyle(.roundedBorder) + } + private func binding(_ key: String) -> Binding { Binding(get:{fields.contains(key)},set:{if $0 { fields.insert(key) } else { fields.remove(key) }}) } + private func fieldRow(_ name: String,key: String,text: Binding) -> some View { HStack { Toggle(name,isOn:binding(key)).frame(width:95,alignment:.leading);TextField(name,text:text).disabled(!fields.contains(key)) } } +} +struct MetadataLookupSheet: View { + @ObservedObject var model: AppModel + let song: Song + @State private var query = "" + @State private var candidates = [MetadataCandidate]() + @State private var busy = false + @State private var error: String? + @Environment(\.dismiss) private var dismiss + var body: some View { + VStack(alignment:.leading,spacing:16) { + Text("Find Recording Metadata").font(.title2.bold()) + Text("Search Apple’s public catalogue for title, artist and album suggestions. Review the recording carefully: karaoke versions may differ. This does not purchase music or provide karaoke audio.").foregroundStyle(.secondary) + HStack { TextField("Title and artist",text:$query).textFieldStyle(.roundedBorder).onSubmit(search);Button(busy ? "Searching…" : "Search",action:search).disabled(busy) } + if let error { Text(error).foregroundStyle(.red) } + List(candidates) { item in + HStack { VStack(alignment:.leading,spacing:4) { Text(item.trackName).fontWeight(.medium);Text(item.artistName+" · "+(item.collectionName ?? "")).font(.caption).foregroundStyle(.secondary) };Spacer() + if let value = item.trackViewUrl,let url = URL(string:value),url.scheme == "https" { Link("View",destination:url) } + Button("Use These Tags") { + do { try model.batchMetadata([song.id],patch:.init(title:item.trackName,artist:item.artistName,album:item.collectionName));dismiss() } catch { self.error = error.localizedDescription } + } + }.padding(.vertical,5) + }.frame(height:300) + HStack { Text("Suggestions update library metadata only.").font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() } } + }.padding(24).frame(width:650).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).onAppear { query = song.title+" "+song.artist } + } + private func search() { guard !busy else { return };busy = true;error = nil;Task { do { candidates = try await OnlineMetadata.shared.search(query) } catch { self.error = error.localizedDescription };busy = false } } +} + +struct StoreCatalogueItem: Identifiable, Codable, Hashable { + var id: String + var title: String + var artist: String + var album: String? + var format: String? + var price: String? + var url: String? + var previewURL: String? + func destination(preview: Bool = false) throws -> URL { + guard let value = preview ? previewURL : url,let url = URL(string:value),["http","https"].contains(url.scheme?.lowercased() ?? ""),url.host != nil,url.user == nil,url.password == nil else { throw LibraryError.message("This catalogue entry has no valid website link") };return url + } + static func decode(_ data: Data) throws -> [Self] { + guard data.count <= 10_000_000 else { throw LibraryError.message("Catalogue files are limited to 10 MB") } + let items = try JSONDecoder().decode([Self].self,from:data) + guard items.count <= 25000,Set(items.map(\.id)).count == items.count,items.allSatisfy({!$0.id.isEmpty && !$0.title.isEmpty && $0.title.count <= 1000}) else { throw LibraryError.message("Use unique catalogue IDs and nonempty song titles; maximum 25,000 entries") } + for item in items { if item.url != nil { _ = try item.destination() };if item.previewURL != nil { _ = try item.destination(preview:true) } };return items + } +} +@MainActor extension AppModel { + func installStoreProfiles() { + perform { + let profiles: [(String,String)] = [("Party Tyme Downloads","https://www.partytyme.net/songshop/cat/"),("Party Tyme Pro Streaming","https://player.partytymeplayer.com/"),("KaraokeCloud","https://karaokecloud.com/"),("KaraokeCloud Pro","https://karaokecloud.com/become-a-karaoke-jockey.html")] + for (name,url) in profiles where !musicStores.contains(where:{$0.name.caseInsensitiveCompare(name) == .orderedSame}) { try library?.saveStore(.init(id:0,name:name,url:url,searchTemplate:"")) };try refresh() + } + } +} +struct StoreCatalogueSheet: View { + @ObservedObject var model: AppModel + let store: MusicStore + @State private var items = [StoreCatalogueItem]() + @State private var query = "" + @State private var sort = "Title" + @State private var message = "" + @Environment(\.dismiss) private var dismiss + private var rows: [StoreCatalogueItem] { items.filter{query.isEmpty || ($0.title+" "+$0.artist+" "+($0.album ?? "")).localizedCaseInsensitiveContains(query)}.sorted{let a = sort == "Artist" ? $0.artist : sort == "Album" ? ($0.album ?? "") : $0.title,b = sort == "Artist" ? $1.artist : sort == "Album" ? ($1.album ?? "") : $1.title;return a.localizedStandardCompare(b) == .orderedAscending} } + var body: some View { + VStack(alignment:.leading,spacing:16) { + Text(store.name+" · Catalogue").font(.title2.bold()) + Text("Import a JSON catalogue supplied by your provider or prepared by you. Website and preview links open in your browser. Direct purchasing, subscription playback, encrypted caching and purchase-history sync are not connected.").foregroundStyle(.secondary) + HStack { TextField("Search catalogue",text:$query).textFieldStyle(.roundedBorder);Picker("Sort",selection:$sort) { Text("Title").tag("Title");Text("Artist").tag("Artist");Text("Album").tag("Album") }.frame(width:170);Button("Import / Reload…",action:importCatalogue) } + List(rows) { item in HStack { VStack(alignment:.leading,spacing:4) { Text(item.title);Text(item.artist+" · "+(item.album ?? "")).font(.caption).foregroundStyle(.secondary) };Spacer();Text(item.format ?? "").font(.caption);Text(item.price ?? "").font(.caption) + if item.previewURL != nil { Button("Preview Website") { open(item,preview:true) } };if item.url != nil { Button("View at Store") { open(item) } } + } }.frame(height:310) + HStack { Text(message.isEmpty ? "\(items.count) catalogue entries saved locally" : message).font(.caption).foregroundStyle(.secondary);Spacer();Button("Done") { dismiss() } } + }.padding(24).frame(width:760).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).onAppear { if let value = try? model.library?.setting("store.catalogue.\(store.id)"),let data = value.data(using:.utf8),let result = try? StoreCatalogueItem.decode(data) { items = result } } + } + private func importCatalogue() { + let panel = NSOpenPanel();panel.allowedContentTypes = [.json];panel.allowsMultipleSelection = false;panel.canChooseDirectories = false + guard panel.runModal() == .OK,let url = panel.url else { return } + do { + let scope = url.startAccessingSecurityScopedResource();defer { if scope { url.stopAccessingSecurityScopedResource() } } + let size = try url.resourceValues(forKeys:[.fileSizeKey]).fileSize ?? 0;guard size <= 10_000_000 else { throw LibraryError.message("Catalogue exceeds 10 MB") } + let data = try Data(contentsOf:url),result = try StoreCatalogueItem.decode(data) + try model.library?.setSetting("store.catalogue.\(store.id)",String(decoding:data,as:UTF8.self));items = result;message = "Catalogue reloaded · \(result.count) entries" + } catch { message = error.localizedDescription } + } + private func open(_ item: StoreCatalogueItem,preview: Bool = false) { do { NSWorkspace.shared.open(try item.destination(preview:preview)) } catch { message = error.localizedDescription } } +} diff --git a/KJamsClone/Player.swift b/KJamsClone/Player.swift new file mode 100644 index 0000000..8ec3a8a --- /dev/null +++ b/KJamsClone/Player.swift @@ -0,0 +1,458 @@ +import SwiftUI +import AVFoundation +import CryptoKit +import CoreImage + +/// CDG follows consumed source frames, so tempo adjustment preserves graphics timing. +@MainActor final class Player: ObservableObject { + @Published var image: CGImage? + var onWillStartPlayback: (() -> Double)? + var onPlaybackReady: (() -> Void)? + private var pendingPlayback: Task? + @Published private(set) var waitingForBackground = false + var audioPlaybackStarted: Bool { node.isPlaying } + var onGlobalMixChanged: ((GlobalMix) -> Void)? + var jogSecondsPerTurn: Double = 2 + var nudgeStep: Double = 0.05 + @Published private(set) var cuePosition: Double = 0 + @Published private(set) var hotCues: [Double?] = [nil,nil,nil,nil] + @Published private(set) var loopIn: Double? + @Published private(set) var loopOut: Double? + @Published var loopEnabled = false + var isShowScreen: Bool { slideImage != nil } + var canJog: Bool { currentSongID != nil && duration > 0 } + func stopDeck() { + guard canJog else { return } + pauseDeck();loopEnabled = false;seek(0) + } + func setCue() { guard canJog else { return };cuePosition = time } + func returnToCue() { guard canJog else { return };pauseDeck();seek(cuePosition) } + private func pauseDeck() { + cancelPendingPlayback() + if slideImage != nil,playing { time = min(duration,slideAnchor+ProcessInfo.processInfo.systemUptime-slideStart);slideAnchor = time } + else if let file,playing,let render = node.lastRenderTime,let current = node.playerTime(forNodeTime:render) { time = min(duration,max(0,anchor+Double(current.sampleTime)/file.processingFormat.sampleRate)) } + node.pause();playing = false;timer?.invalidate();timer = nil + } + func hotCue(_ index: Int,clear: Bool = false) { + guard hotCues.indices.contains(index),canJog else { return } + if clear { hotCues[index] = nil } + else if let value = hotCues[index] { seek(value) } + else { hotCues[index] = time } + } + func markLoopIn() { guard canJog else { return };loopIn = time;loopOut = nil;loopEnabled = false } + func markLoopOut() { + guard let loopIn,time-loopIn >= 0.1 else { return };loopOut = time;loopEnabled = true + } + func clearLoop() { loopEnabled = false;loopIn = nil;loopOut = nil } + func jog(_ seconds: Double) { guard seconds.isFinite,canJog else { return };seek(time+seconds) } + private var graphicsGeneration = UUID() + private var graphicsTask: Task? + private var checkpointTask: Task? + private var checkpoints = [Int:CDGDecoder]() + private var lastGraphicsPacket = -1 + @Published private(set) var outputOpacity: Double = 1 + @Published private(set) var fading = false + var fadeSeconds: Double = 3 + var autoFade = true + private var fadeTask: Task? + private var slideImage: CGImage? + private var slideStart = ProcessInfo.processInfo.systemUptime + private var slideAnchor: Double = 0 + func showScreen(_ image: CGImage,seconds: Double,title: String,songID: Int64) { + stop();slideImage = image;self.image = image;duration = max(3,seconds);self.title = title;currentSongID = songID + slideAnchor = 0;slideStart = ProcessInfo.processInfo.systemUptime;playing = true;startTimer() + } + func fadeOutAndStop() { + guard duration > 0,!fading else { return };fading = true + let token = generation,start = ProcessInfo.processInfo.systemUptime,seconds = max(0.05,fadeSeconds),initial = outputOpacity + fadeTask = Task { [weak self] in + while !Task.isCancelled { + guard let self,self.generation == token else { return } + let fraction = min(1,(ProcessInfo.processInfo.systemUptime-start)/seconds) + self.setFade(initial*(1-fraction)) + if fraction >= 1 { self.stop();return } + try? await Task.sleep(nanoseconds:16_666_667) + } + } + } + private func setFade(_ value: Double) { let next = min(1,max(0,value));if abs(outputOpacity-next) > 0.00001 { outputOpacity = next;node.volume = Float(next) } } + private var videoPlayer: AVPlayer? + private var videoOutput: AVPlayerItemVideoOutput? + private let videoContext = CIContext() + private var mixURL: URL { waveformCacheDirectory.deletingLastPathComponent().appendingPathComponent("GlobalMixer.json") } + private var restoringMix = true + struct GlobalMix: Codable, Equatable { + var volume: Double; var low: Double; var mid: Double; var high: Double; var filter: Double; var enabled: Bool + } + var globalMix: GlobalMix { .init(volume:volume,low:lowGain,mid:midGain,high:highGain,filter:filter,enabled:toneEnabled) } + private func saveMix() { + guard !restoringMix, let data = try? JSONEncoder().encode(globalMix) else { return } + try? FileManager.default.createDirectory(at:mixURL.deletingLastPathComponent(),withIntermediateDirectories:true) + try? data.write(to:mixURL,options:.atomic) + onGlobalMixChanged?(globalMix) + } + @Published private(set) var waveform = [Float]() + @Published private(set) var waveformBands = [WaveformBands]() + @Published var lowGain: Double = 0 { didSet { updateTone() } } + @Published var midGain: Double = 0 { didSet { updateTone() } } + @Published var highGain: Double = 0 { didSet { updateTone() } } + @Published var filter: Double = 0 { didSet { updateTone() } } + @Published var toneEnabled = true { didSet { updateTone() } } + @Published private(set) var leftLevel: Float = 0 + @Published private(set) var rightLevel: Float = 0 + private let tone = AVAudioUnitEQ(numberOfBands:4) + private let meter = AudioLevelMeter() + private var metering = false + @Published private(set) var waveformLoading = false + @Published private(set) var waveformUnavailable = false + @Published private(set) var waveformFromCache = false + @Published private(set) var waveformSaved = false + private let waveformCacheDirectory: URL + private var audioURL: URL? + private var waveformTask: Task? + private var waveformGeneration = UUID() + @Published var playing = false + @Published var time: Double = 0 + @Published var duration: Double = 0 + @Published var title = "Select a song" + @Published var volume: Double = 0.8 { didSet { engine.mainMixerNode.outputVolume = Float(volume); saveMix() } } + @Published var repeating = false + @Published var pitch: Double = 0 { didSet { effect.pitch = Float(pitch * 100) } } + @Published var tempo: Double = 1 { didSet { effect.rate = Float(tempo) } } + @Published var graphicsOffset: Double = 0 { didSet { if graphicsOffset != oldValue { invalidateGraphics();render() } } } + private(set) var currentSongID: Int64? + var onFinished: (() -> Void)? + private let engine = AVAudioEngine() + private let node = AVAudioPlayerNode() + private let effect = AVAudioUnitTimePitch() + private var file: AVAudioFile? + private var anchor: Double = 0 + private var generation = UUID() + private var cdg = Data() + private var decoder = CDGDecoder() + private var timer: Timer? + init(waveformCacheDirectory: URL? = nil) { + self.waveformCacheDirectory = waveformCacheDirectory ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/Waveforms") + engine.attach(node);engine.attach(effect);engine.attach(tone) + if let data = try? Data(contentsOf:mixURL),let mix = try? JSONDecoder().decode(GlobalMix.self,from:data) { + volume = min(1,max(0,mix.volume));lowGain = min(12,max(-12,mix.low));midGain = min(12,max(-12,mix.mid));highGain = min(12,max(-12,mix.high));filter = min(1,max(-1,mix.filter));toneEnabled = mix.enabled + } + restoringMix = false;updateTone() + } + func reanalyseWaveform() { if let audioURL { analyseWaveform(audioURL,force:true) } } + func resetTone() { lowGain = 0; midGain = 0; highGain = 0; filter = 0; toneEnabled = true } + private func updateTone() { + AudioTone.configure(tone,sampleRate:file?.processingFormat.sampleRate ?? 44100,low:lowGain,mid:midGain,high:highGain,filter:filter,enabled:toneEnabled) + saveMix() + } + func load(_ song: Song, preference: SongPreference = .init()) throws { + stop() + do { + let loaded = try AVAudioFile(forReading:URL(fileURLWithPath:song.audio)) + let graphics = try song.cdg.map { try Data(contentsOf:URL(fileURLWithPath:$0)) } ?? Data() + guard loaded.length > 0 else { throw LibraryError.message("Audio file is empty") } + engine.connect(node,to:effect,format:loaded.processingFormat) + engine.connect(effect,to:tone,format:loaded.processingFormat) + engine.connect(tone,to:engine.mainMixerNode,format:loaded.processingFormat) + file = loaded; cdg = graphics; decoder = CDGDecoder();prepareGraphicsCheckpoints() + duration = Double(loaded.length)/loaded.processingFormat.sampleRate + title = song.title; currentSongID = song.id + pitch = preference.pitch; tempo = preference.tempo + updateTone() + let meter = self.meter + engine.mainMixerNode.installTap(onBus:0,bufferSize:1024,format:nil) { buffer, _ in meter.consume(buffer) } + metering = true + engine.mainMixerNode.outputVolume = Float(volume) + engine.prepare(); try engine.start() + schedule(at:0,play:true); startTimer(); tick() + if ["mp4","m4v","mov"].contains(URL(fileURLWithPath:song.audio).pathExtension.lowercased()) { + let item = AVPlayerItem(url:URL(fileURLWithPath:song.audio)) + let output = AVPlayerItemVideoOutput(pixelBufferAttributes:[kCVPixelBufferPixelFormatTypeKey as String:kCVPixelFormatType_32BGRA]) + item.add(output);videoOutput = output;videoPlayer = AVPlayer(playerItem:item);videoPlayer?.isMuted = true + } + audioURL = URL(fileURLWithPath:song.audio) + analyseWaveform(audioURL!) + } catch { stop(); throw error } + } + private func analyseWaveform(_ url: URL, force: Bool = false) { + waveformTask?.cancel() + let token = UUID();waveformGeneration = token;waveformLoading = true;waveformUnavailable = false + let directory = waveformCacheDirectory + waveformTask = Task.detached(priority:.utility) { [weak self] in + let result: Result + var cached = false,saved = false + do { + if !force,let analysis = try? WaveformCache.load(audio:url,directory:directory) { + result = .success(analysis);cached = true;saved = true + } else { + let analysis = try AudioWaveform.analyse(url:url,cancelled:{ Task.isCancelled }) + if !Task.isCancelled { saved = (try? WaveformCache.save(analysis,audio:url,directory:directory)) != nil } + result = .success(analysis) + } + } catch { result = .failure(error) } + guard !Task.isCancelled else { return } + let loadedFromCache = cached,analysisSaved = saved + await MainActor.run { [weak self] in + guard let self,self.waveformGeneration == token else { return } + self.waveformLoading = false;self.waveformFromCache = loadedFromCache;self.waveformSaved = analysisSaved + switch result { + case .success(let analysis):self.waveform = analysis.levels;self.waveformBands = analysis.bands + case .failure:self.waveformUnavailable = true + } + } + } + } + private func cancelPendingPlayback() { pendingPlayback?.cancel();pendingPlayback = nil;waitingForBackground = false } + private func startNodePlayback() { + cancelPendingPlayback() + let delay = onWillStartPlayback?() ?? 0 + guard delay.isFinite,delay > 0 else { onPlaybackReady?();node.play();return } + waitingForBackground = true;let token = generation + pendingPlayback = Task { @MainActor [weak self] in + do { try await Task.sleep(nanoseconds:UInt64(min(15,delay)*1_000_000_000)+20_000_000) } catch { return } + guard let self,self.generation == token,self.playing else { return } + self.waitingForBackground = false;self.pendingPlayback = nil + self.onPlaybackReady?();self.node.play() + } + } + private func schedule(at seconds: Double, play: Bool) { + guard let file else { return } + cancelPendingPlayback();generation = UUID(); let token = generation + node.stop();if !fading { setFade(1) } + let rate = file.processingFormat.sampleRate + let start = min(file.length,max(0,AVAudioFramePosition(seconds*rate))) + anchor = Double(start)/rate; time = anchor + guard start < file.length else { playing = false; render(); return } + node.scheduleSegment(file,startingFrame:start,frameCount:AVAudioFrameCount(min(file.length-start,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack) { [weak self] _ in + Task { @MainActor in + guard let self, self.generation == token else { return } + if self.fading { return } + if self.repeating { self.schedule(at:0,play:true) } + else { + self.playing = false; self.time = self.duration; self.render() + self.timer?.invalidate(); self.timer = nil; self.onFinished?() + } + } + } + if play { startNodePlayback() }; playing = play; render() + } + private func startTimer() { + timer?.invalidate() + timer = Timer(timeInterval:1.0/30,repeats:true) { [weak self] _ in Task { @MainActor in self?.tick() } } + if let timer { RunLoop.main.add(timer,forMode:.common) } + } + func toggle() { + if slideImage != nil { + if playing { tick();slideAnchor = time;playing = false } else { if time >= duration { time = 0;slideAnchor = 0 };slideStart = ProcessInfo.processInfo.systemUptime;playing = true;startTimer() };return + } + guard file != nil else { return } + if playing { cancelPendingPlayback();tick(); node.pause(); playing = false } + else if time >= duration { schedule(at:0,play:true); startTimer() } + else { startNodePlayback(); playing = true; startTimer() } + } + func stop() { + cancelPendingPlayback() + graphicsGeneration = UUID();graphicsTask?.cancel();graphicsTask = nil;checkpointTask?.cancel();checkpointTask = nil;checkpoints = [:];lastGraphicsPacket = -1 + cuePosition = 0;hotCues = [nil,nil,nil,nil];clearLoop() + fadeTask?.cancel();fadeTask = nil;fading = false;setFade(1);slideImage = nil + videoPlayer?.pause();videoPlayer = nil;videoOutput = nil + waveformTask?.cancel(); waveformTask = nil; waveformGeneration = UUID() + waveform = [];waveformBands = [];waveformLoading = false;waveformUnavailable = false;waveformFromCache = false;waveformSaved = false;audioURL = nil + generation = UUID(); timer?.invalidate(); timer = nil; node.stop(); engine.stop() + if metering { engine.mainMixerNode.removeTap(onBus:0); metering = false } + meter.reset(); leftLevel = 0; rightLevel = 0 + engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect); engine.disconnectNodeOutput(tone) + file = nil; playing = false; time = 0; anchor = 0; duration = 0; image = nil; currentSongID = nil; title = "Select a song" + } + func seek(_ value: Double) { + guard value.isFinite else { return } + invalidateGraphics() + if loopEnabled,let loopIn,let loopOut,(value < loopIn || value > loopOut) { loopEnabled = false } + if fading { fadeTask?.cancel();fadeTask = nil;fading = false;setFade(1) } + if slideImage != nil,value.isFinite { time = min(duration,max(0,value));slideAnchor = time;slideStart = ProcessInfo.processInfo.systemUptime;return } + guard file != nil, value.isFinite else { return } + schedule(at:min(max(value,0),duration),play:playing); startTimer() + } + private func render() { + if let slideImage { image = slideImage;return } + if let videoPlayer, let videoOutput { + let target = max(0,time+graphicsOffset) + if abs(videoPlayer.currentTime().seconds-target) > 0.08 { + videoPlayer.seek(to:CMTime(seconds:target,preferredTimescale:600),toleranceBefore:.zero,toleranceAfter:.zero) + } + if playing { videoPlayer.rate = Float(tempo) } else { videoPlayer.pause() } + if let pixels = videoOutput.copyPixelBuffer(forItemTime:videoPlayer.currentTime(),itemTimeForDisplay:nil) { + let frame = CIImage(cvPixelBuffer:pixels);image = videoContext.createCGImage(frame,from:frame.extent) + } + } else { + guard !cdg.isEmpty else { if image != nil { image = nil };return } + let seconds = max(0,time+graphicsOffset),target = min(cdg.count/24,Int(seconds*300)) + guard target != lastGraphicsPacket || image == nil else { return } + guard graphicsTask == nil else { return } + if target < decoder.packetIndex || target-decoder.packetIndex > 900 { + var start = decoder + if target < decoder.packetIndex { start = checkpoints.keys.filter{$0 <= target}.max().flatMap{checkpoints[$0]} ?? CDGDecoder() } + else if let key = checkpoints.keys.filter({$0 <= target && $0 > decoder.packetIndex}).max(),let saved = checkpoints[key] { start = saved } + let initial = start,data = cdg,token = graphicsGeneration + graphicsTask = Task.detached(priority:.userInitiated) { [weak self] in + var decoded = initial;decoded.render(data,at:seconds,cancelled:{Task.isCancelled}) + guard !Task.isCancelled else { return };let frame = decoded.image(),finished = decoded + await MainActor.run { + guard let self,self.graphicsGeneration == token else { return } + self.decoder = finished;self.lastGraphicsPacket = target;self.image = frame;self.graphicsTask = nil + } + } + } else { decoder.render(cdg,at:seconds);lastGraphicsPacket = target;image = decoder.image() } + } + } + private func tick() { + if slideImage != nil { + if playing { time = min(duration,slideAnchor+ProcessInfo.processInfo.systemUptime-slideStart) } + if playing,time >= duration,!fading { + if repeating { time = 0;slideAnchor = 0;slideStart = ProcessInfo.processInfo.systemUptime } + else { playing = false;timer?.invalidate();timer = nil;onFinished?() } + };return + } + let levels = meter.snapshot() + let left = playing ? levels.0 : 0,right = playing ? levels.1 : 0 + if leftLevel != left { leftLevel = left };if rightLevel != right { rightLevel = right } + if playing, let file, let renderTime = node.lastRenderTime, let playerTime = node.playerTime(forNodeTime:renderTime) { + let next = min(duration,max(0,anchor+Double(playerTime.sampleTime)/file.processingFormat.sampleRate)) + if abs(time-next) > 0.0001 { time = next } + if loopEnabled,let loopIn,let loopOut,time >= loopOut { schedule(at:loopIn,play:true);return } + } + if !fading { setFade(autoFade && fadeSeconds > 0 && duration > 0 ? min(1,max(0,(duration-time)/max(0.5,tempo)/fadeSeconds)) : 1) } + render() + } + private func invalidateGraphics() { graphicsGeneration = UUID();graphicsTask?.cancel();graphicsTask = nil;lastGraphicsPacket = -1 } + private func prepareGraphicsCheckpoints() { + guard !cdg.isEmpty else { return } + let data = cdg,interval = max(3000,(cdg.count/24)/180) + checkpointTask = Task.detached(priority:.utility) { [weak self] in + var decoder = CDGDecoder(),saved = [Int:CDGDecoder]() + for packet in stride(from:0,through:data.count/24,by:interval) { + guard !Task.isCancelled else { return };decoder.render(data,at:Double(packet)/300,cancelled:{Task.isCancelled});saved[decoder.packetIndex] = decoder + } + guard !Task.isCancelled else { return };let completed = saved + await MainActor.run { [weak self] in guard let self,self.cdg == data else { return };self.checkpoints = completed } + } + } + +} + +/// Streams fixed-size PCM chunks into a bounded RMS envelope, independent of playback. +/// A separate AVAudioFile keeps analysis away from the player's scheduling/read position. +enum AudioWaveform { + struct Analysis: Codable, Sendable { var levels: [Float]; var bands: [WaveformBands] } + static func read(url: URL, bins: Int = 4096, cancelled: () -> Bool = { false }) throws -> [Float] { + try analyse(url:url,bins:bins,cancelled:cancelled).levels + } + static func analyse(url: URL, bins: Int = 4096, cancelled: () -> Bool = { false }) throws -> Analysis { + guard bins > 0 else { return .init(levels:[],bands:[]) } + let file = try AVAudioFile(forReading: url, commonFormat: .pcmFormatFloat32, interleaved: false) + guard file.length > 0, let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: 16384) else { return .init(levels:[],bands:[]) } + var energy = [Double](repeating: 0, count: bins) + var counts = [Int](repeating: 0, count: bins) + var position: Int64 = 0 + let channels = Int(file.processingFormat.channelCount) + let rate = file.processingFormat.sampleRate + let lowAlpha = exp(-2*Double.pi*250/rate), highAlpha = exp(-2*Double.pi*2500/rate) + var lowState = [Double](repeating:0,count:channels), highState = lowState + var lows = [Double](repeating:0,count:bins), mids = lows, highs = lows + while position < file.length { + if cancelled() { throw CancellationError() } + try file.read(into: buffer, frameCount: AVAudioFrameCount(min(16384, file.length-position))) + let frames = Int(buffer.frameLength) + guard frames > 0, let data = buffer.floatChannelData else { break } + for frame in 0.. 0 ? sqrt($0/Double($1)) : 0 } + let peak = rms.max() ?? 0 + let levels = peak > 0 ? rms.map { Float(pow(min(1,$0/peak),0.7)) } : [Float](repeating:0,count:bins) + let bands = (0.. (Float,Float) { lock.lock();defer { lock.unlock() };return levels } + func reset() { lock.lock();levels = (0,0);lock.unlock() } +} + +/// Persistent analysis cache: file path, size, modification time and analyser version form its key. +/// Invalid or obsolete entries are treated as misses, never as playback errors. +enum WaveformCache { + static func location(audio: URL,directory: URL) throws -> URL { + let values = try FileManager.default.attributesOfItem(atPath:audio.path) + let stamp = (values[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 + let size = (values[.size] as? NSNumber)?.int64Value ?? 0 + let signature = "rgb-rms-v2-4096|\(audio.standardizedFileURL.path)|\(size)|\(stamp)" + let key = SHA256.hash(data:Data(signature.utf8)).map { String(format:"%02x",$0) }.joined() + return directory.appendingPathComponent(key+".waveform") + } + static func load(audio: URL,directory: URL) throws -> AudioWaveform.Analysis { + let url = try location(audio:audio,directory:directory) + let data = try Data(contentsOf:url) + guard data.count < 4_000_000 else { throw LibraryError.message("Invalid waveform cache") } + let analysis = try PropertyListDecoder().decode(AudioWaveform.Analysis.self,from:data) + guard analysis.levels.count == 4096,analysis.bands.count == 4096, + analysis.levels.allSatisfy({ $0.isFinite && $0 >= 0 && $0 <= 1 }), + analysis.bands.allSatisfy({ $0.low.isFinite && $0.mid.isFinite && $0.high.isFinite && $0.low >= 0 && $0.mid >= 0 && $0.high >= 0 }) else { throw LibraryError.message("Invalid waveform cache") } + return analysis + } + static func save(_ analysis: AudioWaveform.Analysis,audio: URL,directory: URL) throws { + try FileManager.default.createDirectory(at:directory,withIntermediateDirectories:true) + let encoder = PropertyListEncoder();encoder.outputFormat = .binary + try encoder.encode(analysis).write(to:location(audio:audio,directory:directory),options:.atomic) + } +} + diff --git a/KJamsClone/SingerServer.swift b/KJamsClone/SingerServer.swift new file mode 100644 index 0000000..95c3be9 --- /dev/null +++ b/KJamsClone/SingerServer.swift @@ -0,0 +1,211 @@ +import Foundation +import Network +import SwiftUI +import AppKit +import Darwin + +struct SingerHTTPRequest: Sendable { + var method: String;var target: String;var headers: [String:String];var body: Data +} +struct SingerHTTPResponse: Sendable { + var status: Int = 200;var type = "application/json; charset=utf-8";var body: Data + static func json(_ value: Any,status: Int = 200) -> Self { .init(status:status,body:(try? JSONSerialization.data(withJSONObject:value,options:[.sortedKeys])) ?? Data("{}".utf8)) } + var wire: Data { + let reason = [200:"OK",400:"Bad Request",401:"Unauthorized",403:"Forbidden",404:"Not Found",405:"Method Not Allowed",413:"Payload Too Large",500:"Internal Server Error",503:"Service Unavailable"][status] ?? "Error" + let header = "HTTP/1.1 \(status) \(reason)\r\nContent-Type: \(type)\r\nContent-Length: \(body.count)\r\nConnection: close\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\nContent-Security-Policy: default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'\r\n\r\n" + return Data(header.utf8)+body + } +} +enum SingerHTTPParser { + static func parse(_ data: Data) throws -> SingerHTTPRequest? { + guard data.count <= 32768 else { throw LibraryError.message("Request too large") } + guard let boundary = data.range(of:Data([13,10,13,10])) else { if data.count > 8192 { throw LibraryError.message("Headers too large") };return nil } + guard boundary.lowerBound <= 8192,let header = String(data:data[..= 0,n <= 16384 else { throw LibraryError.message("Invalid body size") };length = n } else { length = 0 } + let end = boundary.upperBound+length + guard data.count >= end else { return nil } + guard data.count == end else { throw LibraryError.message("Pipelined requests are unsupported") } + return .init(method:start[0],target:start[1],headers:headers,body:Data(data[boundary.upperBound.. SingerHTTPResponse + init(handler: @escaping @Sendable (SingerHTTPRequest) async -> SingerHTTPResponse) { self.handler = handler } + func start(port: UInt16,loopback: Bool,state: @escaping @Sendable (Bool,String?) -> Void) throws { + let parameters = NWParameters.tcp + if loopback { parameters.requiredInterfaceType = .loopback } + let listener = try NWListener(using:parameters,on:NWEndpoint.Port(rawValue:port)!) + self.listener = listener + listener.stateUpdateHandler = { value in + switch value { case .ready:state(true,nil);case .failed(let error):state(false,error.localizedDescription);case .cancelled:state(false,nil);default:break } + } + listener.newConnectionHandler = { [weak self] connection in + guard let self else { connection.cancel();return } + if Date().timeIntervalSince(self.second) >= 1 { self.second = Date();self.requestsThisSecond = 0 };self.requestsThisSecond += 1 + guard self.connections.count < 48,self.requestsThisSecond <= 80 else { connection.cancel();return } + let id = UUID();self.connections[id] = connection;connection.start(queue:self.queue) + self.queue.asyncAfter(deadline:.now()+8) { [weak self] in if let connection = self?.connections.removeValue(forKey:id) { connection.cancel() } } + self.receive(connection,id:id,data:Data()) + } + listener.start(queue:queue) + } + private func receive(_ connection: NWConnection,id: UUID,data: Data) { + connection.receive(minimumIncompleteLength:1,maximumLength:4096) { [weak self] bytes,_,complete,error in + guard let self,self.connections[id] != nil else { return } + var data = data;if let bytes { data.append(bytes) } + do { + if let request = try SingerHTTPParser.parse(data) { + Task { + let response = await self.handler(request) + self.queue.async { guard self.connections[id] != nil else { return };connection.send(content:response.wire,completion:.contentProcessed { _ in self.queue.async { self.connections.removeValue(forKey:id);connection.cancel() } }) } + } + } else if complete || error != nil { self.connections.removeValue(forKey:id);connection.cancel() } + else { self.receive(connection,id:id,data:data) } + } catch { + connection.send(content:SingerHTTPResponse.json(["error":"Invalid or oversized request"],status:400).wire,completion:.contentProcessed { _ in self.queue.async { self.connections.removeValue(forKey:id);connection.cancel() } }) + } + } + } + func stop() { queue.async { self.listener?.cancel();self.listener = nil;for connection in self.connections.values { connection.cancel() };self.connections.removeAll() } } + deinit { listener?.cancel() } +} +@MainActor final class SingerWebServer: ObservableObject { + @Published private(set) var running = false + @Published private(set) var starting = false + @Published private(set) var error: String? + @Published private(set) var port: UInt16 = 8080 + private var transport: SingerTransport? + private var generation = UUID() + func start(port: UInt16,loopback: Bool = false,handler: @escaping @Sendable (SingerHTTPRequest) async -> SingerHTTPResponse) { + guard transport == nil,port >= 1024 else { return } + let token = UUID();generation = token;starting = true;error = nil;self.port = port + let transport = SingerTransport(handler:handler);self.transport = transport + do { try transport.start(port:port,loopback:loopback) { [weak self] running,error in Task { @MainActor in + guard let self,self.generation == token else { return };self.running = running;self.starting = false;self.error = error + if error != nil { self.transport?.stop();self.transport = nil } + } } } catch { self.error = error.localizedDescription;self.transport = nil;starting = false } + } + func stop() { generation = UUID();transport?.stop();transport = nil;running = false;starting = false } + var localURL: String { + var address = "localhost",head: UnsafeMutablePointer? + if getifaddrs(&head) == 0 { + defer { freeifaddrs(head) };var cursor = head + while let entry = cursor { + defer { cursor = entry.pointee.ifa_next } + guard let socket = entry.pointee.ifa_addr,socket.pointee.sa_family == UInt8(AF_INET),entry.pointee.ifa_flags & UInt32(IFF_LOOPBACK) == 0 else { continue } + var host = [CChar](repeating:0,count:Int(NI_MAXHOST)) + if getnameinfo(socket,socklen_t(socket.pointee.sa_len),&host,socklen_t(host.count),nil,0,NI_NUMERICHOST) == 0 { address = String(cString:host);break } + } + } + return "http://\(address):\(port)" + } + deinit { transport?.stop() } +} +@MainActor extension AppModel { + func startSingerServer(port: UInt16? = nil,loopback: Bool = false) { + guard currentVenue != nil else { error = "Choose a venue first";return } + singerServer.start(port:port ?? UInt16(studio.serverPort),loopback:loopback) { [weak self] request in + guard let self else { return .json(["error":"Host unavailable"],status:503) } + return await self.singerRequest(request) + } + } + func singerRequest(_ request: SingerHTTPRequest) -> SingerHTTPResponse { + guard let components = URLComponents(string:"http://localhost"+request.target) else { return .json(["error":"Invalid URL"],status:400) } + if request.method == "GET",components.path == "/" { return .init(type:"text/html; charset=utf-8",body:Data(Self.singerHTML.utf8)) } + guard components.path.hasPrefix("/api/"),["GET","POST"].contains(request.method) else { return .json(["error":"Not found"],status:404) } + if let origin = request.headers["origin"],origin != "http://"+(request.headers["host"] ?? "") { return .json(["error":"Origin is not allowed"],status:403) } + guard let library,let currentVenue,let auth = request.headers["authorization"],auth.hasPrefix("Bearer "),let singer = try? library.singerForToken(String(auth.dropFirst(7)),venue:currentVenue) else { return .json(["error":"Your singer invitation is missing, expired, or for another venue"],status:401) } + func songJSON(_ song: Song) -> [String:Any] { let preference = (try? library.preference(song:song.id,singer:singer)) ?? .init();return ["id":song.id,"title":song.title,"artist":song.artist,"album":song.album,"duration":song.duration,"pitch":preference.pitch,"tempo":preference.tempo,"rating":preference.rating,"favourite":favourites[singer]?.contains(song.id) == true] } + do { + if request.method == "GET",components.path == "/api/catalog" { + let q = components.queryItems?.first{$0.name == "q"}?.value ?? "" + guard q.count <= 300 else { throw LibraryError.message("Search is too long") } + let offset = max(0,min(100000,Int(components.queryItems?.first{$0.name == "offset"}?.value ?? "0") ?? 0)) + let matches = songs.filter{!$0.audio.hasPrefix("showscreen:") && SongSearch.matches($0,query:q)} + return .json(["songs":Array(matches.dropFirst(offset).prefix(50)).map(songJSON),"total":matches.count,"offset":offset]) + } + if request.method == "GET",components.path == "/api/me" { + let tonight = tonightSongs(for:singer),repertoire = self.songs(for:singer) + return .json(["singer":singers.first{$0.id == singer}?.name ?? "Singer","venue":currentVenueName,"tonight":tonight.map(songJSON),"repertoire":repertoire.map(songJSON),"history":try library.history(venue:currentVenue,singer:singer).prefix(100).map{["title":$0.song,"date":$0.date]}]) + } + guard request.method == "POST",request.headers["content-type"]?.lowercased().hasPrefix("application/json") == true,let body = try JSONSerialization.jsonObject(with:request.body) as? [String:Any],let number = body["song"] as? NSNumber else { throw LibraryError.message("Send a song and JSON action") } + let id = number.int64Value + guard number.doubleValue == Double(id),let song = songs.first(where:{$0.id == id}),!song.audio.hasPrefix("showscreen:") else { throw LibraryError.message("Song unavailable") } + if components.path == "/api/tonight" { + switch body["action"] as? String { + case "add":try library.addTonight(song:id,singer:singer,venue:currentVenue) + case "remove":try library.removeTonight(song:id,singer:singer,venue:currentVenue) + case "move":try library.placeTonight(song:id,singer:singer,venue:currentVenue,before:(body["before"] as? NSNumber)?.int64Value,addIfNeeded:false) + default:throw LibraryError.message("Unknown list action") + } + } else if components.path == "/api/preferences" { + guard let pitch = body["pitch"] as? Double,let tempo = body["tempo"] as? Double,let rating = body["rating"] as? Int else { throw LibraryError.message("Pitch, tempo and rating are required") } + try library.savePreference(song:id,singer:singer,value:.init(pitch:pitch,tempo:tempo,rating:rating)) + } else if components.path == "/api/favourite" { + guard let included = body["included"] as? Bool else { throw LibraryError.message("Favourite value required") };try library.setFavourite(song:id,singer:singer,included:included) + } else { return .json(["error":"Not found"],status:404) } + try refresh();return .json(["ok":true]) + } catch { return .json(["error":error.localizedDescription],status:400) } + } + static let singerHTML = #""" + KJMix Pro · Singer + +

KJMix Pro

Your songs. Your next performance.

+

+

My Song Settings

+ + """# +} +struct SingerServerPane: View { + @ObservedObject var model: AppModel + @ObservedObject var server: SingerWebServer + @State private var port = "8080" + @State private var address = "" + @State private var invitation: String? + var body: some View { + VStack(alignment:.leading,spacing:18) { + HStack { Circle().fill(server.running ? .green : .secondary).frame(width:8,height:8);Text(server.running ? "Singer Web is running" : server.starting ? "Starting…" : "Singer Web is off").font(.headline);Spacer() + TextField("Port",text:$port).frame(width:80).disabled(server.running || server.starting) + Button(server.running || server.starting ? "Stop Server" : "Start Server") { + if server.running || server.starting { server.stop() } else if let port = UInt16(port),port >= 1024 { model.startSingerServer(port:port) } else { model.error = "Use a port from 1024 to 65535" } + }.disabled(model.currentVenue == nil) + } + Text("Singers on the same network can search your library, manage their own Tonight and favourites, and save key, tempo and ratings. Each invitation is restricted to one singer at this venue. The server stays off until you start it.").foregroundStyle(.secondary) + HStack { Text("Address");TextField("http://your-mac.local:8080",text:$address);Button("Open") { if let url = URL(string:address),["http","https"].contains(url.scheme ?? "") { NSWorkspace.shared.open(url) } }.disabled(!server.running) } + Text("Use this Mac’s local hostname or Wi-Fi IP address, including the port. This server uses HTTP on your local network; invitations grant access to that singer’s lists. Do not publish them.").font(.caption).foregroundStyle(.secondary) + if let error = server.error { Text(error).foregroundStyle(.red) } + List(model.venueSingers) { singer in + HStack { Text(singer.name);Spacer() + Button("Copy Invitation") { model.perform { guard let library = model.library,let venue = model.currentVenue,let url = URL(string:address),url.scheme == "http",url.host != nil else { throw LibraryError.message("Enter this Mac’s local server address first") };let token = try library.singerToken(singer:singer.id,venue:venue);let value = address.trimmingCharacters(in:CharacterSet(charactersIn:"/"))+"/#"+token;NSPasteboard.general.clearContents();NSPasteboard.general.setString(value,forType:.string);invitation = "Invitation copied for "+singer.name } }.disabled(!server.running) + Button("Revoke") { model.perform { guard let venue = model.currentVenue else { return };try model.library?.revokeSinger(singer:singer.id,venue:venue);invitation = "Invitation revoked for "+singer.name } } + }.padding(.vertical,4) + } + if let invitation { Text(invitation).font(.callout).foregroundStyle(.secondary) } + }.padding(22).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.dark).onAppear { port = String(server.running ? Int(server.port) : model.studio.serverPort);address = server.localURL }.onChange(of:server.running) { running in if running { address = server.localURL } } + } +} diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift new file mode 100644 index 0000000..f01033f --- /dev/null +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -0,0 +1,1129 @@ +import XCTest +import SwiftUI +import AVFoundation +import AppKit +import UniformTypeIdentifiers +@testable import KJamsClone + +final class KJamsCloneTests: XCTestCase { + func packet(_ instruction: UInt8, _ data: [UInt8]) -> [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] } + func testPresetTileXORAndInvalidCoordinates() { + var d = CDGDecoder() + d.apply(packet(1,[3,0])); XCTAssertEqual(d.pixels[0],3) + d.apply(packet(6,[0,5,1,1] + Array(repeating:63,count:12))) + XCTAssertEqual(d.pixels[12*300+6],5) + d.apply(packet(38,[0,5,1,1] + Array(repeating:63,count:12))) + XCTAssertEqual(d.pixels[12*300+6],0) + let old = d.pixels; d.apply(packet(6,[1,2,31,63])); XCTAssertEqual(d.pixels,old) + } + func testSeekAndTruncatedPacket() { + var d = CDGDecoder() + let data = Data(packet(1,[2,0]) + packet(1,[7,0]) + [9]) + d.render(data,at:1); XCTAssertEqual(d.pixels[0],7); XCTAssertEqual(d.packetIndex,2) + d.render(data,at:1.0/300); XCTAssertEqual(d.pixels[0],2) + d.render(data,at:0); XCTAssertEqual(d.pixels[0],0) + } + func testPaletteBorderScrollAndImage() { + var d = CDGDecoder() + d.apply(packet(30,[63,63])); XCTAssertEqual(d.palette[0],0xFFFFFFFF) + d.apply(packet(2,[4])); XCTAssertEqual(d.pixels[0],4); XCTAssertEqual(d.pixels[12*300+6],0) + d.apply(packet(20,[3,16,0])); XCTAssertEqual(d.pixels[100*300],3) + d.apply(packet(24,[0,32,0])); XCTAssertEqual(d.pixels[100*300+294],3) + XCTAssertEqual(d.image()?.width,288) + } + func testSQLitePersistenceDedupRatingsAndHistory() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let url = folder.appendingPathComponent("test.sqlite") + do { + let db = try Library(url:url) + try db.add(title:"Singer's Song",audio:"/test.mp3",cdg:nil) + try db.add(title:"Duplicate",audio:"/test.mp3",cdg:"/test.cdg") + let song = try XCTUnwrap(db.songs().first) + try db.rate(song.id,5); XCTAssertThrowsError(try db.rate(song.id,6)) + try db.addEntity("singers",name:"O'Neil"); try db.addEntity("venues",name:"Hall") + try db.record(song.id,singer:db.entities("singers").first?.id,venue:db.entities("venues").first?.id) + } + let reopened = try Library(url:url); let songs = try reopened.songs() + XCTAssertEqual(songs.count,1); XCTAssertEqual(songs[0].rating,5); XCTAssertEqual(songs[0].plays,1); XCTAssertEqual(songs[0].cdg,"/test.cdg") + XCTAssertEqual(try reopened.entities("singers").first?.name,"O'Neil") + } + func testPairedImportAndContentDedup() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let audio = root.appendingPathComponent("Test.mp3"), cdg = root.appendingPathComponent("Test.CDG") + try Data([1,2,3]).write(to:audio); try Data(packet(1,[2,0])).write(to:cdg) + let db = try Library(url:root.appendingPathComponent("test.sqlite")) + let importer = Importer(media:root.appendingPathComponent("Media")) + try importer.run([audio],library:db); try importer.run([audio],library:db) + XCTAssertEqual(try db.songs().count,1) + XCTAssertNotNil(try db.songs().first?.cdg) + } + + func testZippedMP3GImportAndUnsafeArchiveRejection() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let db = try Library(url:folder.appendingPathComponent("db.sqlite")) + let importer = Importer(media:folder.appendingPathComponent("Media")) + try importer.run([fixtures.appendingPathComponent("Demo.zip")],library:db) + let song = try XCTUnwrap(db.songs().first) + XCTAssertEqual(song.title,"Demo"); XCTAssertNotNil(song.cdg) + XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio)) + XCTAssertThrowsError(try importer.run([fixtures.appendingPathComponent("Unsafe.zip")],library:db)) + } + @MainActor func testMP3PlaybackPauseAndSeek() throws { + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let player = Player() + defer { player.stop() } + try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0)) + XCTAssertGreaterThan(player.duration,3.9); XCTAssertTrue(player.playing) + player.toggle(); XCTAssertFalse(player.playing) + player.seek(2); XCTAssertEqual(player.time,2,accuracy:0.05); XCTAssertNotNil(player.image) + player.seek(0.1); XCTAssertEqual(player.time,0.1,accuracy:0.05) + } + @MainActor func testCreateSingerAndVenueThroughAppModel() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let model = AppModel(storageURL: root) + XCTAssertNil(model.error) + let singer = try model.createEntity(.singer, name: " Alex ") + let venue = try model.createEntity(.venue, name: "Town Hall") + XCTAssertEqual(model.singers.first?.name, "Alex") + XCTAssertEqual(model.venues.first?.name, "Town Hall") + XCTAssertEqual(try model.createEntity(.singer, name: "Alex"), singer) + XCTAssertEqual(model.singers.count, 1) + XCTAssertThrowsError(try model.createEntity(.venue, name: " ")) + let reopened = AppModel(storageURL: root) + XCTAssertEqual(reopened.singers.first?.id, singer) + XCTAssertEqual(reopened.venues.first?.id, venue) + } + + @MainActor func testVenuesShareSingersAndRememberSelection() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root) + let hall = try model.createEntity(.venue,name:"Hall") + let alex = try model.createEntity(.singer,name:"Alex") + let club = try model.createEntity(.venue,name:"Club") + XCTAssertTrue(model.venueSingers.isEmpty) + model.membership(singer:alex,included:true) + XCTAssertEqual(model.venueSingers.map(\.id),[alex]) + model.chooseVenue(hall); XCTAssertEqual(model.venueSingers.map(\.id),[alex]) + model.chooseVenue(club); model.membership(singer:alex,included:false) + XCTAssertTrue(model.venueSingers.isEmpty) + let reopened = AppModel(storageURL:root) + XCTAssertEqual(reopened.currentVenue,club); XCTAssertTrue(reopened.venueSingers.isEmpty) + reopened.chooseVenue(hall); XCTAssertEqual(reopened.venueSingers.map(\.id),[alex]) + XCTAssertEqual(reopened.singers.count,1) + } + func testMigrationAndMembershipRemovalDoesNotReappear() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let url = root.appendingPathComponent("db.sqlite") + var singer: Int64 = 0, venue: Int64 = 0 + do { + let db = try Library(url:url) + try db.add(title:"Song",audio:"/a.mp3",cdg:nil) + try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall") + singer = try XCTUnwrap(db.entities("singers").first?.id); venue = try XCTUnwrap(db.entities("venues").first?.id) + try db.record(XCTUnwrap(db.songs().first?.id),singer:singer,venue:venue) + try db.execute("PRAGMA user_version=1") + } + do { + let migrated = try Library(url:url) + XCTAssertEqual(try migrated.members(of:venue).map(\.id),[singer]) + try migrated.setMembership(singer:singer,venue:venue,included:false) + } + let reopened = try Library(url:url) + XCTAssertTrue(try reopened.members(of:venue).isEmpty) + XCTAssertEqual(try reopened.history(venue:venue).count,1) + } + func testQueuePlaylistsPreferencesAndDeletion() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let db = try Library(url:root.appendingPathComponent("db.sqlite")) + try db.add(title:"First",audio:"/1.mp3",cdg:nil) + try db.add(title:"Second",audio:"/2.mp3",cdg:nil) + let songs = try db.songs() + try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall"); try db.addEntity("venues",name:"Club") + let singer = try XCTUnwrap(db.entities("singers").first?.id) + let hall = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Hall" })?.id) + let club = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Club" })?.id) + XCTAssertThrowsError(try db.enqueue(song:songs[0].id,singer:singer,venue:hall)) + try db.setMembership(singer:singer,venue:hall,included:true) + try db.enqueue(song:songs[0].id,singer:singer,venue:hall); try db.enqueue(song:songs[1].id,singer:singer,venue:hall) + XCTAssertTrue(try db.queue(venue:club).isEmpty) + let second = try XCTUnwrap(db.queue(venue:hall).last) + try db.moveQueue(second.id,venue:hall,by:-1) + XCTAssertEqual(try db.queue(venue:hall).first?.songID,songs[1].id) + let preference = SongPreference(pitch:3,tempo:0.85,rating:4) + try db.savePreference(song:songs[0].id,singer:singer,value:preference) + XCTAssertEqual(try db.preference(song:songs[0].id,singer:singer),preference) + XCTAssertThrowsError(try db.savePreference(song:songs[0].id,singer:singer,value:.init(pitch:99))) + try db.addEntity("playlists",name:"Party") + let playlist = try XCTUnwrap(db.entities("playlists").first?.id) + try db.addToPlaylist(playlist,song:songs[0].id); try db.addToPlaylist(playlist,song:songs[0].id) + XCTAssertEqual(try db.playlistSongIDs(playlist),[songs[0].id]) + try db.record(songs[0].id,singer:singer,venue:hall) + try db.rename("singers",id:singer,name:"Alex Jones") + XCTAssertEqual(try db.history(venue:hall).first?.singer,"Alex Jones") + try db.deleteEntity("singers",id:singer) + XCTAssertTrue(try db.queue(venue:hall).isEmpty) + XCTAssertEqual(try db.history(venue:hall).count,1) + try db.selectVenue(hall); try db.deleteEntity("venues",id:hall) + XCTAssertNil(try db.currentVenue()); XCTAssertEqual(try db.history(venue:nil).count,1) + } + @MainActor func testTempoSourceClockPauseAndResume() async throws { + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let player = Player(); defer { player.stop() } + let song = Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0) + try player.load(song,preference:.init(pitch:2,tempo:1.5,rating:3)) + XCTAssertEqual(player.pitch,2); XCTAssertEqual(player.tempo,1.5) + try await Task.sleep(nanoseconds:500_000_000) + XCTAssertGreaterThan(player.time,0.45); XCTAssertLessThan(player.time,1.3) + player.toggle(); let paused = player.time + try await Task.sleep(nanoseconds:100_000_000) + XCTAssertEqual(player.time,paused,accuracy:0.025) + player.seek(1.5); player.toggle() + try await Task.sleep(nanoseconds:200_000_000) + XCTAssertGreaterThan(player.time,1.6); XCTAssertNotNil(player.image) + } + func testNewTestMediaMetadataAndCDGPairImport() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let db = try Library(url:root.appendingPathComponent("db.sqlite")) + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let importer = Importer(media:root.appendingPathComponent("Media")) + try importer.run([fixtures.appendingPathComponent("TestTrack.cdg")],library:db) + let song = try XCTUnwrap(db.songs().first) + XCTAssertEqual(song.title,"MP3 Plus G Test") + XCTAssertEqual(song.artist,"KJamsClone Test Generator") + XCTAssertNotNil(song.cdg) + let bytes = try Data(contentsOf:fixtures.appendingPathComponent("TestTrack.cdg")) + XCTAssertEqual(bytes.count,20*300*24) + var decoder = CDGDecoder(); decoder.render(bytes,at:2) + XCTAssertTrue(decoder.pixels.contains(1)); XCTAssertTrue(decoder.pixels.contains(2)) + } + @MainActor func testAutomaticVenueQueueAdvancesAndRecordsSinger() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root); defer { model.player.stop() } + _ = try model.createEntity(.venue,name:"Test Hall") + _ = try model.createEntity(.singer,name:"Alex") + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + model.importFiles([fixtures.appendingPathComponent("Demo.zip")]) + let song = try XCTUnwrap(model.songs.first) + model.enqueue(song.id); model.enqueue(song.id); model.playQueueNext() + XCTAssertEqual(model.queue.count,1); XCTAssertEqual(model.history.count,1) + model.player.seek(model.player.duration-0.1) + for _ in 0..<25 { + if model.queue.isEmpty { break } + try await Task.sleep(nanoseconds:100_000_000) + } + XCTAssertNil(model.error); XCTAssertTrue(model.queue.isEmpty) + XCTAssertEqual(model.history.count,2); XCTAssertEqual(model.history.first?.singer,"Alex") + } + func testWaveformRepresentsSilenceAndSignalInTimeOrder() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let url = folder.appendingPathComponent("signal.wav") + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:8000,channels:1)) + do { + let file = try AVAudioFile(forWriting:url,settings:format.settings) + let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:4096)); buffer.frameLength = 4096 + let samples = try XCTUnwrap(buffer.floatChannelData?[0]) + for i in 0..<4096 { samples[i] = i < 2048 ? 0 : Float(sin(Double(i)*0.2)*0.5) } + try file.write(from:buffer) + } + let envelope = try AudioWaveform.read(url:url,bins:32) + XCTAssertEqual(envelope.count,32) + XCTAssertTrue(envelope.prefix(16).allSatisfy { $0 == 0 }) + XCTAssertTrue(envelope.suffix(16).allSatisfy { $0 > 0.8 && $0 <= 1 }) + XCTAssertThrowsError(try AudioWaveform.read(url:url,cancelled:{true})) + } + @MainActor func testWaveformAnalysisAndStopClearsTrackData() async throws { + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let player = Player(); defer { player.stop() } + try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:nil,rating:0,plays:0)) + player.toggle() + for _ in 0..<50 { + if !player.waveformLoading { break } + try await Task.sleep(nanoseconds:20_000_000) + } + XCTAssertEqual(player.waveform.count,4096) + XCTAssertFalse(player.waveformUnavailable) + player.stop() + XCTAssertTrue(player.waveform.isEmpty) + XCTAssertFalse(player.waveformLoading) + try await Task.sleep(nanoseconds:50_000_000) + XCTAssertTrue(player.waveform.isEmpty) + } + + @MainActor func testConnectedSingerCreationAndSharedRepertoire() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root) + let hall = try model.createEntity(.venue,name:"Hall") + try model.library?.add(title:"Song",audio:"/sample.mp3",cdg:nil);try model.refresh() + let song = try XCTUnwrap(model.songs.first?.id) + let alex = try model.createSinger(name:" Alex ",venue:hall,songs:[song]) + XCTAssertEqual(model.selectedSinger,alex);XCTAssertEqual(model.currentVenue,hall) + XCTAssertEqual(model.venueSingers.map(\.id),[alex]);XCTAssertEqual(model.songs(for:alex).map(\.id),[song]) + XCTAssertThrowsError(try model.createSinger(name:"Broken",venue:hall,songs:[999999])) + XCTAssertFalse(try XCTUnwrap(model.library).entities("singers").contains { $0.name == "Broken" }) + let club = try model.createEntity(.venue,name:"Club");model.membership(singer:alex,included:true) + XCTAssertEqual(model.songs(for:alex).map(\.id),[song]) + model.assign(song,to:alex);XCTAssertEqual(model.songs(for:alex).count,1) + let reopened = AppModel(storageURL:root) + XCTAssertEqual(reopened.currentVenue,club);XCTAssertEqual(reopened.songs(for:alex).map(\.id),[song]) + reopened.assign(song,to:alex,included:false) + XCTAssertTrue(AppModel(storageURL:root).songs(for:alex).isEmpty) + } + func testQueueRemembersSingerSongWithoutDuplicatingAssignment() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let db = try Library(url:root.appendingPathComponent("db.sqlite")) + try db.addEntity("singers",name:"Alex");try db.addEntity("venues",name:"Hall") + try db.add(title:"Song",audio:"/a.mp3",cdg:nil) + let singer = try XCTUnwrap(db.entities("singers").first?.id),venue = try XCTUnwrap(db.entities("venues").first?.id),song = try XCTUnwrap(db.songs().first?.id) + try db.setMembership(singer:singer,venue:venue,included:true) + try db.enqueue(song:song,singer:singer,venue:venue);try db.enqueue(song:song,singer:singer,venue:venue) + XCTAssertEqual(try db.singerSongIDs()[singer],[song]);XCTAssertEqual(try db.queue(venue:venue).count,2) + try db.removeSingerSong(song,singer:singer) + XCTAssertNil(try db.singerSongIDs()[singer]);XCTAssertEqual(try db.songs().count,1) + } + func testFrequencyBandsDistinguishBassAndTreble() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let url = root.appendingPathComponent("bands.wav") + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:16000,channels:1)) + do { + let file = try AVAudioFile(forWriting:url,settings:format.settings) + let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:8192));buffer.frameLength = 8192 + for i in 0..<8192 { buffer.floatChannelData![0][i] = Float(sin(Double(i)*2*Double.pi*(i < 4096 ? 80 : 6000)/16000)*0.2) } + try file.write(from:buffer) + } + let analysis = try AudioWaveform.analyse(url:url,bins:32) + XCTAssertGreaterThan(analysis.bands[8].low,analysis.bands[8].high*3) + XCTAssertGreaterThan(analysis.bands[24].high,analysis.bands[24].low*3) + } + private func toneRMS(frequency: Double, low: Double = 0, filter: Double = 0, enabled: Bool = true) throws -> Double { + let engine = AVAudioEngine(),node = AVAudioPlayerNode(),eq = AVAudioUnitEQ(numberOfBands:4) + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:1)) + engine.attach(node);engine.attach(eq);engine.connect(node,to:eq,format:format);engine.connect(eq,to:engine.mainMixerNode,format:format) + AudioTone.configure(eq,sampleRate:44100,low:low,mid:0,high:0,filter:filter,enabled:enabled) + try engine.enableManualRenderingMode(.offline,format:format,maximumFrameCount:1024) + let input = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:32768));input.frameLength = 32768 + for i in 0..<32768 { input.floatChannelData![0][i] = Float(sin(Double(i)*2*Double.pi*frequency/44100)*0.1) } + node.scheduleBuffer(input);try engine.start();node.play();defer { node.stop();engine.stop() } + let output = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:1024)) + var energy: Double = 0,count = 0 + for block in 0..<12 { + let status = try engine.renderOffline(1024,to:output) + XCTAssertEqual(status,.success) + if block >= 2 { for i in 0.. QueueEntry in + let singer: Int64 = i < 4 ? 1 : 2 + return QueueEntry(id:Int64(i+1),songID:Int64(i+1),singerID:singer,title:"Song",singer:singer == 1 ? "A" : "B") + } + XCTAssertEqual(SingerRotation.plan(queue:queue,roster:roster,after:nil).map{$0.singerID!},[1,2,1,2,1,1]) + XCTAssertEqual(SingerRotation.plan(queue:queue,roster:roster,after:1).first?.singerID,2) + var departed = roster;departed[0].here = false + XCTAssertEqual(SingerRotation.plan(queue:queue,roster:departed,after:1).map{$0.singerID!},[2,2]) + XCTAssertEqual(SingerRotation.plan(queue:queue,roster:[],after:nil).count,0) + } + @MainActor func testRotationSettingsRosterAndPersonalRatingsPersistPerVenue() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root),venue = try model.createEntity(.venue,name:"Hall"),a = try model.createEntity(.singer,name:"A"),b = try model.createEntity(.singer,name:"B") + let db = try XCTUnwrap(model.library),id = try db.add(title:"Song",audio:"/test.mp3",cdg:nil) + try model.refresh();let song = try XCTUnwrap(model.songs.first) + model.selectedSinger = a;model.updateTuning(id,pitch:3,tempo:1.2);model.rateSong(song,5) + model.selectedSinger = b;model.updateTuning(id,pitch:-2,tempo:0.8);model.rateSong(song,2) + model.markHere(a,true);model.markHere(b,true);model.moveSinger(b,by:-1) + model.hostSettings.smart = true;model.hostSettings.cursor = b;model.saveHostSettings() + let reopened = AppModel(storageURL:root);XCTAssertEqual(reopened.currentVenue,venue);XCTAssertTrue(reopened.hostSettings.smart);XCTAssertEqual(reopened.hostSettings.cursor,b) + XCTAssertEqual(reopened.rotationSingers.map(\.id),[b,a]) + XCTAssertEqual(try db.preference(song:id,singer:a),.init(pitch:3,tempo:1.2,rating:5));XCTAssertEqual(try db.preference(song:id,singer:b),.init(pitch:-2,tempo:0.8,rating:2));XCTAssertEqual(try db.song(id)?.rating,0) + _ = try model.createEntity(.venue,name:"Other");XCTAssertFalse(model.hostSettings.smart);XCTAssertTrue(model.rotationSingers.isEmpty) + model.chooseVenue(venue);XCTAssertTrue(model.hostSettings.smart) + XCTAssertThrowsError(try db.savePreference(song:id,singer:a,value:.init(pitch:.nan,tempo:1,rating:0))) + } + func testBatchMetadataIsAtomicAndPlaylistOrderPersists() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) } + let db = try Library(url:root.appendingPathComponent("db.sqlite")),a = try db.add(title:"Old A",audio:"/a.mp3",cdg:nil),b = try db.add(title:"Old B",audio:"/b.mp3",cdg:nil) + try db.batchMetadata([a,b],patch:.init(artist:"Shared",album:"Album",find:"Old",replacement:"New")) + XCTAssertEqual(try db.song(a)?.title,"New A");XCTAssertEqual(try db.song(b)?.artist,"Shared") + XCTAssertThrowsError(try db.batchMetadata([a,b],patch:.init(title:""))) + XCTAssertThrowsError(try db.batchMetadata([a,999],patch:.init(artist:"Wrong"))) + XCTAssertEqual(try db.song(a)?.artist,"Shared") + try db.addEntity("playlists",name:"Show");let playlist = try XCTUnwrap(db.entities("playlists").first).id + try db.addToPlaylist(playlist,song:a);try db.addToPlaylist(playlist,song:b);try db.reorderPlaylist(playlist,song:b,by:-1) + let reopened = try Library(url:root.appendingPathComponent("db.sqlite"));XCTAssertEqual(try reopened.playlistSongIDs(playlist),[b,a]) + } + func testTargetedMultiTermQuotedExactAndNumericSearch() { + let song = Song(id:52,title:"Love Me Tonight",audio:"/x",cdg:nil,rating:4,plays:0,artist:"The Beatles",album:"Live, Now") + for q in ["beat,love,me","artist:beat title:\"love me\"","album:=\"Live, Now\"","52","rating:4","title:Tonight"] { XCTAssertTrue(SongSearch.matches(song,query:q),q) } + for q in ["artist:love","=Love","53","beat missing"] { XCTAssertFalse(SongSearch.matches(song,query:q),q) } + } + @MainActor func testShowScreensPersistRenderPauseSeekAndFinish() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root);_ = try model.createEntity(.venue,name:"Hall") + let db = try XCTUnwrap(model.library),screen = ShowScreen(title:"Welcome",body:"Hello {venue}; Next: {next}",seconds:3) + let id = try db.saveScreen(screen);try model.refresh();let saved = try XCTUnwrap(model.showScreens.first) + XCTAssertEqual(saved.id,id);XCTAssertEqual(saved.expanded("{venue}/{next}",venue:"Hall",singer:"A",next:"B",song:"Song"),"Hall/B") + try model.playScreen(saved);XCTAssertEqual(model.player.image?.width,1920);XCTAssertEqual(model.player.image?.height,1080) + model.player.toggle();XCTAssertFalse(model.player.playing);model.player.seek(2.95) + var finished = 0;model.player.onFinished = { finished += 1 };model.player.toggle() + try await Task.sleep(nanoseconds:160_000_000) + XCTAssertEqual(finished,1);XCTAssertFalse(model.player.playing);model.player.stop() + XCTAssertEqual(try Library(url:root.appendingPathComponent("Library.sqlite")).screens().first?.title,"Welcome") + } + @MainActor func testFadePreservesGlobalMasterAndCancelsForNextSong() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } + let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms")),song = Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0) + player.volume = 0.47;player.lowGain = 2;let mix = player.globalMix + try player.load(song);player.seek(3.8);try await Task.sleep(nanoseconds:80_000_000) + XCTAssertLessThan(player.outputOpacity,0.2);XCTAssertEqual(player.globalMix,mix) + player.fadeSeconds = 0.12;player.fadeOutAndStop();try await Task.sleep(nanoseconds:220_000_000) + XCTAssertNil(player.currentSongID);XCTAssertEqual(player.globalMix,mix) + try player.load(song);player.fadeOutAndStop();try player.load(song);try await Task.sleep(nanoseconds:180_000_000) + XCTAssertEqual(player.currentSongID,1);XCTAssertFalse(player.fading);player.stop() + } + func testMP3FileTagWritingBacksUpPreservesAudioAndRejectsBadTags() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) } + let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")),url = root.appendingPathComponent("Demo.mp3"),backup = root.appendingPathComponent("Backups") + try FileManager.default.copyItem(at:fixture,to:url) + let original = try Data(contentsOf:url),before = try AudioWaveform.read(url:url,bins:64) + let song = Song(id:1,title:"Updated ✓",audio:url.path,cdg:nil,rating:0,plays:0,artist:"New Artist",album:"New Album",label:"Publisher") + try await FileTagWriter.write(song,backupDirectory:backup) + XCTAssertEqual(try AudioWaveform.read(url:url,bins:64),before) + let files = try FileManager.default.contentsOfDirectory(at:backup,includingPropertiesForKeys:nil);XCTAssertEqual(files.count,1);XCTAssertEqual(try Data(contentsOf:files[0]),original) + let metadata = try await AVURLAsset(url:url).load(.commonMetadata) + XCTAssertEqual(metadata.first{$0.commonKey == .commonKeyTitle}?.stringValue,"Updated ✓") + var second = song;second.title = "Second";try await FileTagWriter.write(second,backupDirectory:backup) + XCTAssertEqual(try AudioWaveform.read(url:url,bins:64),before) + } + @MainActor func testSingerAPIIsolationRevocationAndPersonalSettings() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root),venue = try model.createEntity(.venue,name:"Hall"),a = try model.createEntity(.singer,name:"A"),b = try model.createEntity(.singer,name:"B"),db = try XCTUnwrap(model.library) + let song = try db.add(title:"Private & ",audio:"/private/media.mp3",cdg:nil),other = try db.add(title:"Other",audio:"/other.mp3",cdg:nil) + let token = try db.singerToken(singer:a,venue:venue);_ = try db.saveScreen(.init());try model.refresh() + func request(_ method: String,_ target: String,_ body: [String:Any]? = nil,_ auth: String? = token) throws -> SingerHTTPResponse { + var headers = ["host":"localhost:8080","content-type":"application/json"];if let auth { headers["authorization"] = "Bearer "+auth } + return model.singerRequest(.init(method:method,target:target,headers:headers,body:try body.map{try JSONSerialization.data(withJSONObject:$0)} ?? Data())) + } + XCTAssertEqual(try request("GET","/api/catalog",nil,nil).status,401) + let catalogue = try request("GET","/api/catalog");XCTAssertEqual(catalogue.status,200) + let text = String(decoding:catalogue.body,as:UTF8.self);XCTAssertFalse(text.contains("/private/media"));XCTAssertFalse(text.contains("showscreen"));XCTAssertFalse(text.contains("cdg")) + XCTAssertEqual(try request("POST","/api/tonight",["song":song,"action":"add","singer":b]).status,200) + XCTAssertEqual(model.tonightSongIDs[a],[song]);XCTAssertNil(model.tonightSongIDs[b]) + _ = try request("POST","/api/tonight",["song":other,"action":"add"]) + _ = try request("POST","/api/tonight",["song":other,"action":"move","before":song]);XCTAssertEqual(model.tonightSongIDs[a],[other,song]) + XCTAssertEqual(try request("POST","/api/preferences",["song":song,"pitch":2,"tempo":1.1,"rating":5]).status,200) + XCTAssertEqual(try db.preference(song:song,singer:a),.init(pitch:2,tempo:1.1,rating:5));XCTAssertEqual(try db.preference(song:song,singer:b).rating,0) + XCTAssertEqual(try request("POST","/api/preferences",["song":song,"pitch":100,"tempo":1,"rating":5]).status,400) + try db.revokeSinger(singer:a,venue:venue);XCTAssertEqual(try request("GET","/api/me").status,401) + let token2 = try db.singerToken(singer:a,venue:venue);XCTAssertNotEqual(token,token2) + _ = try model.createEntity(.venue,name:"Elsewhere");XCTAssertEqual(try request("GET","/api/me",nil,token2).status,401) + } + func testHTTPParserRejectsSmugglingOversizeAndWaitsForBody() throws { + XCTAssertNil(try SingerHTTPParser.parse(Data("POST /api/tonight HTTP/1.1\r\nContent-Length: 4\r\n\r\n{}".utf8))) + for text in ["GET / HTTP/1.1\r\nContent-Length: 0\r\nContent-Length: 1\r\n\r\n", "POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n", "POST / HTTP/1.1\r\nContent-Length: 20000\r\n\r\n", "GET / HTTP/1.1\r\n\r\nextra"] { XCTAssertThrowsError(try SingerHTTPParser.parse(Data(text.utf8))) } + XCTAssertThrowsError(try SingerHTTPParser.parse(Data(repeating:65,count:33000))) + let parsed = try XCTUnwrap(SingerHTTPParser.parse(Data("GET /api/me HTTP/1.1\r\nHost: localhost\r\nAuthorization: Bearer abc\r\n\r\n".utf8))) + XCTAssertEqual(parsed.headers["authorization"],"Bearer abc") + } + @MainActor func testSingerWebServerRealLoopbackHTTP() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root),venue = try model.createEntity(.venue,name:"Hall"),singer = try model.createEntity(.singer,name:"Singer"),db = try XCTUnwrap(model.library) + _ = try db.add(title:"Searchable",audio:"/not-exposed.mp3",cdg:nil);try model.refresh();let token = try db.singerToken(singer:singer,venue:venue) + let port = UInt16.random(in:20000...45000);model.startSingerServer(port:port,loopback:true);defer { model.singerServer.stop() } + for _ in 0..<100 { if model.singerServer.running || model.singerServer.error != nil { break };try await Task.sleep(nanoseconds:20_000_000) } + XCTAssertTrue(model.singerServer.running,model.singerServer.error ?? "Server did not start") + var request = URLRequest(url:URL(string:"http://127.0.0.1:\(port)/api/catalog?q=Search")!);request.timeoutInterval = 4;request.setValue("Bearer "+token,forHTTPHeaderField:"Authorization") + let (data,response) = try await URLSession.shared.data(for:request) + XCTAssertEqual((response as? HTTPURLResponse)?.statusCode,200);XCTAssertTrue(String(decoding:data,as:UTF8.self).contains("Searchable"));XCTAssertFalse(String(decoding:data,as:UTF8.self).contains("not-exposed")) + let (_,page) = try await URLSession.shared.data(from:URL(string:"http://127.0.0.1:\(port)/")!);XCTAssertEqual((page as? HTTPURLResponse)?.statusCode,200) + request.setValue(nil,forHTTPHeaderField:"Authorization");let (_,unauthorized) = try await URLSession.shared.data(for:request);XCTAssertEqual((unauthorized as? HTTPURLResponse)?.statusCode,401) + } + @MainActor func testAllStoreProfilesAndCatalogueValidation() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root);model.installStoreProfiles();model.installStoreProfiles();XCTAssertEqual(model.musicStores.count,4) + let item = StoreCatalogueItem(id:"1",title:"Original",artist:"Test",url:"https://example.com/song") + let data = try JSONEncoder().encode([item]);XCTAssertEqual(try StoreCatalogueItem.decode(data),[item]) + XCTAssertThrowsError(try StoreCatalogueItem.decode(JSONEncoder().encode([item,item]))) + var invalid = item;invalid.previewURL = "file:///private/local";XCTAssertThrowsError(try StoreCatalogueItem.decode(JSONEncoder().encode([invalid]))) + } + + func testMP4TagExportPreservesVideoAndAudio() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) } + let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/VideoDemo.mp4")),url = root.appendingPathComponent("Video.mp4") + try FileManager.default.copyItem(at:fixture,to:url) + let before = try AudioWaveform.read(url:url,bins:64) + let song = Song(id:1,title:"Tagged Video",audio:url.path,cdg:nil,rating:0,plays:0,artist:"Test Artist") + try await FileTagWriter.write(song,backupDirectory:root.appendingPathComponent("Backups")) + XCTAssertEqual(try AudioWaveform.read(url:url,bins:64),before) + let asset = AVURLAsset(url:url),tracks = try await asset.loadTracks(withMediaType:.video) + XCTAssertEqual(tracks.count,1) + let metadata = try await asset.load(.commonMetadata) + XCTAssertEqual(metadata.first{$0.commonKey == .commonKeyTitle}?.stringValue,"Tagged Video") + } + + @MainActor func testDJStopCueHotCuesJogAndLoopKeepTrackLoaded() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } + let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms")),song = Song(id:1,title:"DJ",audio:fixture.path,cdg:nil,rating:0,plays:0) + try player.load(song);player.stopDeck() + XCTAssertEqual(player.currentSongID,1);XCTAssertFalse(player.playing);XCTAssertEqual(player.time,0,accuracy:0.01);XCTAssertGreaterThan(player.duration,3) + player.seek(0.5);player.setCue();player.hotCue(0);player.jog(0.25);XCTAssertEqual(player.time,0.75,accuracy:0.02) + player.returnToCue();XCTAssertEqual(player.time,0.5,accuracy:0.02);player.seek(1);player.hotCue(0);XCTAssertEqual(player.time,0.5,accuracy:0.02) + player.hotCue(0,clear:true);XCTAssertNil(player.hotCues[0]) + player.seek(0.5);player.markLoopIn();player.seek(0.75);player.markLoopOut();XCTAssertTrue(player.loopEnabled) + player.seek(0.6);player.toggle();try await Task.sleep(nanoseconds:380_000_000) + XCTAssertTrue(player.playing);XCTAssertLessThan(player.time,0.85);XCTAssertGreaterThanOrEqual(player.time,0.49) + player.stopDeck();XCTAssertFalse(player.loopEnabled);XCTAssertEqual(player.cuePosition,0.5,accuracy:0.01) + player.stop();XCTAssertNil(player.currentSongID);XCTAssertEqual(player.cuePosition,0);XCTAssertNil(player.loopIn) + } + @MainActor func testManualBackgroundStopsBeforeSingerAndNeverAutoResumes() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } + let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let model = AppModel(storageURL:root);defer { model.player.stop();model.backgroundDeck.unload() } + _ = try model.createEntity(.venue,name:"Hall");let singer = try model.createEntity(.singer,name:"Singer") + let id = try XCTUnwrap(model.library).add(title:"Song",audio:fixture.path,cdg:nil);try model.refresh();let song = try XCTUnwrap(model.songs.first{$0.id == id}) + model.studio.automaticBackground = false;model.studio.backgroundPath = fixture.path;XCTAssertTrue(model.backgroundDeck.loaded);XCTAssertFalse(model.backgroundDeck.playing) + model.player.volume = 0.42;model.player.lowGain = 3;XCTAssertEqual(model.backgroundDeck.mix,model.player.globalMix) + model.playBackground();XCTAssertTrue(model.backgroundDeck.playing) + try await Task.sleep(nanoseconds:100_000_000) + try model.play(song,singer:singer);XCTAssertFalse(model.backgroundDeck.playing);XCTAssertTrue(model.backgroundDeck.interruptedBySinger);XCTAssertTrue(model.player.playing) + model.playBackground();XCTAssertFalse(model.backgroundDeck.playing);model.error = nil + model.player.stopDeck();XCTAssertFalse(model.backgroundDeck.playing) + model.playBackground();XCTAssertTrue(model.backgroundDeck.playing) + model.player.toggle();XCTAssertFalse(model.backgroundDeck.playing);XCTAssertTrue(model.backgroundDeck.interruptedBySinger) + model.player.stop();XCTAssertFalse(model.backgroundDeck.playing) + model.backgroundDeck.stop();XCTAssertEqual(model.backgroundDeck.time,0) + } + @MainActor func testSettingsPersistWithoutAutoplayAndPreserveMaster() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } + let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let model = AppModel(storageURL:root) + var settings = model.studio;settings.autoAdvance = false;settings.jogSecondsPerTurn = 4;settings.nudgeSeconds = 0.1;settings.graphicsOffset = 0.25;settings.backgroundPath = fixture.path;settings.backgroundTrim = 0.17;settings.backgroundLoop = false;settings.serverPort = 18080;model.studio = settings + model.player.volume = 0.31;model.player.midGain = -2 + let reopened = AppModel(storageURL:root);defer { reopened.backgroundDeck.unload();model.backgroundDeck.unload() } + XCTAssertEqual(reopened.studio,settings);XCTAssertFalse(reopened.autoAdvance);XCTAssertEqual(reopened.player.jogSecondsPerTurn,4);XCTAssertEqual(reopened.player.graphicsOffset,0.25) + try await Task.sleep(nanoseconds:400_000_000) + XCTAssertTrue(reopened.backgroundDeck.loaded);XCTAssertFalse(reopened.backgroundDeck.playing);XCTAssertEqual(reopened.backgroundDeck.trim,0.17);XCTAssertFalse(reopened.backgroundDeck.repeating) + XCTAssertEqual(reopened.player.volume,0.31);XCTAssertEqual(reopened.backgroundDeck.mix.mid,-2);XCTAssertFalse(reopened.singerServer.running) + } + @MainActor func testBulkTuningCacheSwitchesSingersAndUpdatesWithoutLibraryReload() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root);_ = try model.createEntity(.venue,name:"Hall");let a = try model.createEntity(.singer,name:"A"),b = try model.createEntity(.singer,name:"B"),db = try XCTUnwrap(model.library) + let id = try db.add(title:"Test",audio:"/test.mp3",cdg:nil) + try db.saveDefaultPreference(song:id,value:.init(pitch:1,tempo:1.1));try db.savePreference(song:id,singer:a,value:.init(pitch:3,tempo:0.9,rating:5));try model.refresh() + model.selectedSinger = a;XCTAssertEqual(model.preference(id),.init(pitch:3,tempo:0.9,rating:5)) + model.selectedSinger = b;XCTAssertEqual(model.preference(id).pitch,1) + let songs = model.songs;model.updateTuning(id,pitch:-2);XCTAssertEqual(model.preference(id).pitch,-2);XCTAssertEqual(model.songs,songs) + model.selectedSinger = a;XCTAssertEqual(model.preference(id).pitch,3) + } + @MainActor func testRotationDragTargetsExplicitVenueAndReordersRoster() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root);_ = try model.createEntity(.venue,name:"Hall");let a = try model.createEntity(.singer,name:"A"),b = try model.createEntity(.singer,name:"B") + model.markAllSingersHere();let reference = try XCTUnwrap(model.rotationReference(b)),decoded = try XCTUnwrap(RotationDrag.parse(reference.text)) + try model.applyRotationDrop(decoded,before:a);XCTAssertEqual(model.rotationSingers.map(\.id),[b,a]) + var foreign = decoded;foreign.session = "foreign";XCTAssertThrowsError(try model.applyRotationDrop(foreign,before:nil)) + _ = try model.createEntity(.venue,name:"Other");XCTAssertThrowsError(try model.applyRotationDrop(decoded,before:nil)) + } + @MainActor func testActualSingerDisclosurePressAndNavigationRetainExpansion() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root);_ = try model.createEntity(.venue,name:"Hall");let singer = try model.createEntity(.singer,name:"Alex") + let host = NSHostingView(rootView:LibraryView(model:model).frame(width:1320,height:900).preferredColorScheme(.dark)) + host.frame = NSRect(x:0,y:0,width:1320,height:900) + let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false);window.appearance = NSAppearance(named:.darkAqua);window.contentView = host + let previous = NSApp.keyWindow + window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil);window.makeKey() + defer { window.orderOut(nil);window.contentView = nil;previous?.makeKey() } + try await Task.sleep(nanoseconds:200_000_000);host.layoutSubtreeIfNeeded();host.displayIfNeeded() + if let image = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:image) } + func click(_ x: Double,_ top: Double) async throws { + let point = NSPoint(x:x,y:900-top) + for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] { + let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:point,modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseDown ? 1 : 0)) + NSApp.sendEvent(event) + } + try await Task.sleep(nanoseconds:90_000_000) + } + try await click(24,336) + XCTAssertTrue(model.expandedSingerIDs.contains(singer),"Clicking the actual chevron must expand the singer") + try await click(100,153);XCTAssertEqual(model.activeSection,"Rotation") + try await click(100,116);XCTAssertEqual(model.activeSection,"Library") + XCTAssertTrue(model.expandedSingerIDs.contains(singer)) + try await click(24,336) + XCTAssertFalse(model.expandedSingerIDs.contains(singer));XCTAssertNil(model.error) + } + + @MainActor func testRapidCDGJogCannotPublishAnOldSeekFrame() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) } + let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")),graphics = root.appendingPathComponent("Seek.cdg") + var packets = [[UInt8]](repeating:packet(0,[]),count:1200) + packets[0] = packet(30,[0,0,60,0,3,48,0,15]);packets[300] = packet(1,[1,0]);packets[600] = packet(1,[2,0]);packets[900] = packet(1,[3,0]) + try Data(packets.flatMap{$0}).write(to:graphics) + let player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms"));defer { player.stop() } + try player.load(.init(id:1,title:"Seek",audio:audio.path,cdg:graphics.path,rating:0,plays:0));player.stopDeck() + player.seek(3.5);player.seek(1.2) + try await Task.sleep(nanoseconds:120_000_000) + let red = try XCTUnwrap(player.image?.dataProvider?.data) as Data + XCTAssertEqual(Array(red.prefix(3)),[255,0,0]) + player.seek(2.4);try await Task.sleep(nanoseconds:80_000_000) + let green = try XCTUnwrap(player.image?.dataProvider?.data) as Data;XCTAssertEqual(Array(green.prefix(3)),[0,255,0]) + player.seek(0.2);try await Task.sleep(nanoseconds:80_000_000) + let black = try XCTUnwrap(player.image?.dataProvider?.data) as Data;XCTAssertEqual(Array(black.prefix(3)),[0,0,0]) + } + + @MainActor func testAutomaticBackgroundHandoffAndManualOverride() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let model = AppModel(storageURL:root);defer { model.backgroundHandoff?.cancel();model.player.stop();model.backgroundDeck.unload() } + _ = try model.createEntity(.venue,name:"Hall") + let id = try XCTUnwrap(model.library).add(title:"Song",audio:fixture.path,cdg:nil);try model.refresh() + model.studio.backgroundPath = fixture.path + try await Task.sleep(nanoseconds:400_000_000);XCTAssertTrue(model.backgroundDeck.playing) + try model.play(try XCTUnwrap(model.songs.first{$0.id == id}));XCTAssertFalse(model.backgroundDeck.playing) + model.player.stopDeck();try await Task.sleep(nanoseconds:400_000_000);XCTAssertTrue(model.backgroundDeck.playing) + model.pauseBackground();XCTAssertFalse(model.studio.backgroundAutoEnabled) + model.player.toggle();model.player.stopDeck();try await Task.sleep(nanoseconds:400_000_000);XCTAssertFalse(model.backgroundDeck.playing) + model.studio.automaticBackground = true;try await Task.sleep(nanoseconds:400_000_000);XCTAssertTrue(model.backgroundDeck.playing) + model.stopBackground();XCTAssertFalse(model.backgroundDeck.playing);XCTAssertEqual(model.backgroundDeck.time,0) + } + @MainActor func testNewSingerSongsImmediatelyJoinSmartRotation() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root),db = try XCTUnwrap(model.library) + let venue = try model.createEntity(.venue,name:"Hall") + let song = try db.add(title:"Song",audio:"/demo.mp3",cdg:nil) + let singer = try model.createSinger(name:"Alex",venue:venue,songs:[song]) + model.hostSettings.smart = true;model.saveHostSettings() + XCTAssertTrue(model.expandedSingerIDs.contains(singer));XCTAssertTrue(model.rotationSingers.contains{$0.id == singer && $0.here}) + XCTAssertEqual(model.rotationPlan.map(\.songID),[song]);XCTAssertEqual(model.tonightSongIDs[singer],[song]) + model.markHere(singer,false);XCTAssertTrue(model.rotationPlan.isEmpty) + model.addToTonight(song,singer:singer);XCTAssertEqual(model.rotationPlan.map(\.songID),[song]) + } + + @MainActor func testNaturalFinishAdvancesSingerBeforeBackgroundThenResumesWhenEmpty() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let model = AppModel(storageURL:root);defer { model.backgroundHandoff?.cancel();model.player.stop();model.backgroundDeck.unload() } + let venue = try model.createEntity(.venue,name:"Hall"),db = try XCTUnwrap(model.library) + let song = try db.add(title:"Song",audio:audio.path,cdg:nil) + let a = try model.createSinger(name:"A",venue:venue,songs:[song]),b = try model.createSinger(name:"B",venue:venue,songs:[song]) + model.hostSettings.smart = true;model.hostSettings.autoFade = false;model.saveHostSettings() + model.studio.backgroundPath = audio.path + model.playQueueNext();XCTAssertEqual(model.playbackSinger,a) + model.player.seek(model.player.duration-0.15) + try await Task.sleep(nanoseconds:650_000_000) + XCTAssertEqual(model.playbackSinger,b);XCTAssertTrue(model.player.playing);XCTAssertFalse(model.backgroundDeck.playing);XCTAssertTrue(model.queue.isEmpty) + model.player.seek(model.player.duration-0.15) + try await Task.sleep(nanoseconds:750_000_000) + XCTAssertFalse(model.player.playing);XCTAssertTrue(model.backgroundDeck.playing) + } + + @MainActor func testRotationStartPauseResumeStopAndEmptyExplanation() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root);defer { model.player.stop() } + model.startRotation();XCTAssertNotNil(model.error);model.error = nil + let venue = try model.createEntity(.venue,name:"Hall") + model.startRotation();XCTAssertNotNil(model.error);model.error = nil + let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let song = try XCTUnwrap(model.library).add(title:"Song",audio:audio.path,cdg:nil) + _ = try model.createSinger(name:"A",venue:venue,songs:[song]) + model.studio.autoAdvance = false;model.startRotation() + XCTAssertTrue(model.rotationPlaybackActive);XCTAssertTrue(model.player.playing);XCTAssertTrue(model.autoAdvance);XCTAssertTrue(model.queue.isEmpty) + model.pauseRotation();XCTAssertFalse(model.player.playing);model.startRotation();XCTAssertTrue(model.player.playing) + model.stopRotation();XCTAssertFalse(model.player.playing);XCTAssertFalse(model.rotationPlaybackActive);XCTAssertFalse(model.autoAdvance) + model.startRotation();XCTAssertTrue(model.player.playing);XCTAssertTrue(model.rotationPlaybackActive);XCTAssertNil(model.error) + } + @MainActor func testUnifiedRosterDropRoutesSingerAndSongProviders() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root),db = try XCTUnwrap(model.library) + let venue = try model.createEntity(.venue,name:"Hall") + let a = try model.createSinger(name:"A",venue:venue,songs:[]),b = try model.createSinger(name:"B",venue:venue,songs:[]) + let song = try db.add(title:"Song",audio:"/demo.mp3",cdg:nil);try model.refresh() + let reference = try XCTUnwrap(model.rotationReference(b)) + XCTAssertTrue(model.acceptRosterDrop([NSItemProvider(object:reference.text as NSString)],target:a)) + try await Task.sleep(nanoseconds:150_000_000);XCTAssertEqual(model.rotationSingers.map(\.id),[b,a]) + XCTAssertTrue(model.acceptRosterDrop([model.dragReference(song).provider()],target:b)) + try await Task.sleep(nanoseconds:150_000_000);XCTAssertEqual(model.tonightSongIDs[b],[song]);XCTAssertEqual(model.queue.first?.singerID,b) + do { try await model.applyRosterDropText("unrelated text",target:a);XCTFail("Must reject foreign drops") } catch {} + } + @MainActor func testVisibleSingerReorderUpdatesManualAndSmartPlansAndPersists() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root),db = try XCTUnwrap(model.library) + let venue = try model.createEntity(.venue,name:"Hall"),song = try db.add(title:"Song",audio:"/demo.mp3",cdg:nil) + let a = try model.createSinger(name:"A",venue:venue,songs:[song]),b = try model.createSinger(name:"B",venue:venue,songs:[song]) + XCTAssertFalse(model.hostSettings.smart);model.reorderRotationSinger(b,by:-1) + XCTAssertEqual(model.rotationPlan.compactMap(\.singerID),[b,a]) + model.hostSettings.smart = true;model.hostSettings.cursor = b;model.saveHostSettings() + model.reorderRotationSinger(a,by:-1);XCTAssertNil(model.hostSettings.cursor) + XCTAssertEqual(model.rotationPlan.compactMap(\.singerID),[a,b]) + let reopened = AppModel(storageURL:root);XCTAssertEqual(reopened.rotationSingers.map(\.id),[a,b]);XCTAssertNil(model.error) + } + + @MainActor func testActualRotationButtonsAddSongReorderAndStart() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root);defer { model.player.stop() } + let venue = try model.createEntity(.venue,name:"Hall") + let a = try model.createSinger(name:"Alex",venue:venue,songs:[]),b = try model.createSinger(name:"Sam",venue:venue,songs:[]) + let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let song = try XCTUnwrap(model.library).add(title:"Demo",audio:audio.path,cdg:nil);try model.refresh();model.activeSection = "Rotation";model.selectedSinger = a + let host = NSHostingView(rootView:LibraryView(model:model).frame(width:1060,height:760).preferredColorScheme(.dark));host.frame = NSRect(x:0,y:0,width:1060,height:760) + let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false);window.appearance = NSAppearance(named:.darkAqua);window.contentView = host + let previous = NSApp.keyWindow;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil);window.makeKey() + defer { window.orderOut(nil);window.contentView = nil;previous?.makeKey() } + try await Task.sleep(nanoseconds:250_000_000);host.layoutSubtreeIfNeeded() + func capture(_ name: String) throws { + host.layoutSubtreeIfNeeded();if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try rep.representation(using:.png,properties:[:])!.write(to:URL(fileURLWithPath:"/tmp/kjmix-rotation-test-"+name+".png")) } + } + func click(_ x: Double,_ top: Double) async throws { + for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] { + let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:NSPoint(x:x,y:760-top),modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseDown ? 1 : 0));NSApp.sendEvent(event) + } + try await Task.sleep(nanoseconds:120_000_000) + } + try await click(485,453);try capture("songs") + try await click(990,516);try capture("added") + XCTAssertEqual(model.tonightSongIDs[a],[song],"Actual Add to Tonight button must enqueue the song") + try await click(518,458);try capture("reordered") + XCTAssertEqual(model.rotationSingers.map(\.id),[b,a],"Visible down arrow must move Alex after Sam") + try await click(335,338) + XCTAssertTrue(model.player.playing,"Actual Start Rotation button must play");XCTAssertTrue(model.rotationPlaybackActive);XCTAssertEqual(model.playbackSinger,a);XCTAssertNil(model.error) + } + + @MainActor func testBackgroundSeekRetainsPlaybackStateAndSingerInterlock() async throws { + let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let deck = BackgroundDeck();defer { deck.unload() };try deck.load(audio) + deck.seek(1.5);XCTAssertEqual(deck.time,1.5,accuracy:0.01);XCTAssertFalse(deck.playing) + try deck.start();deck.seek(2);XCTAssertTrue(deck.playing) + try await Task.sleep(nanoseconds:150_000_000);XCTAssertGreaterThan(deck.time,2) + deck.pauseForSinger();deck.seek(1);XCTAssertFalse(deck.playing);XCTAssertTrue(deck.interruptedBySinger);XCTAssertEqual(deck.time,1,accuracy:0.01) + deck.seek(-10);XCTAssertEqual(deck.time,0);deck.seek(.nan);XCTAssertEqual(deck.time,0) + try deck.start();deck.seek(deck.duration+10);XCTAssertFalse(deck.playing);XCTAssertEqual(deck.time,deck.duration,accuracy:0.01) + deck.seek(0.5);try deck.start();XCTAssertTrue(deck.playing);XCTAssertEqual(deck.time,0.5,accuracy:0.01) + } + + @MainActor func testBackgroundFadesSaveSettingsAndCancelOnSeekOrSinger() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let model = AppModel(storageURL:root);defer { model.backgroundDeck.unload() } + model.studio.automaticBackground = false;model.studio.backgroundPath = audio.path + model.studio.backgroundFadeIn = 0.25;model.studio.backgroundFadeOut = 0.25 + model.playBackground();XCTAssertEqual(model.backgroundDeck.outputGain,0,accuracy:0.001) + try await Task.sleep(nanoseconds:100_000_000);XCTAssertGreaterThan(model.backgroundDeck.outputGain,0);XCTAssertLessThan(model.backgroundDeck.outputGain,Float(model.player.volume*model.studio.backgroundTrim)) + try await Task.sleep(nanoseconds:250_000_000);XCTAssertTrue(model.backgroundDeck.playing) + model.pauseBackground();XCTAssertFalse(model.backgroundDeck.playing) + try await Task.sleep(nanoseconds:350_000_000);XCTAssertTrue(model.backgroundDeck.status.hasPrefix("Paused"));XCTAssertEqual(model.backgroundDeck.outputGain,0,accuracy:0.001) + model.backgroundDeck.seek(1);model.playBackground();model.backgroundDeck.pauseForSinger() + try await Task.sleep(nanoseconds:350_000_000);XCTAssertFalse(model.backgroundDeck.playing);XCTAssertTrue(model.backgroundDeck.interruptedBySinger) + model.playBackground();model.stopBackground();model.backgroundDeck.seek(1.5) + try await Task.sleep(nanoseconds:350_000_000);XCTAssertEqual(model.backgroundDeck.time,1.5,accuracy:0.01);XCTAssertFalse(model.backgroundDeck.playing) + let reopened = AppModel(storageURL:root);defer { reopened.backgroundDeck.unload() } + XCTAssertEqual(reopened.backgroundDeck.fadeInSeconds,0.25);XCTAssertEqual(reopened.backgroundDeck.fadeOutSeconds,0.25) + } + + @MainActor func testSingerWaitsForAudibleBackgroundFadeOut() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root);defer { model.player.stop();model.backgroundDeck.unload() } + let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + _ = try model.createEntity(.venue,name:"Hall") + let id = try XCTUnwrap(model.library).add(title:"Singer",audio:audio.path,cdg:nil);try model.refresh() + model.studio.automaticBackground = false;model.studio.backgroundPath = audio.path;model.studio.backgroundFadeOut = 0.4 + model.playBackground();let initial = model.backgroundDeck.outputGain;XCTAssertGreaterThan(initial,0) + try model.play(try XCTUnwrap(model.songs.first{$0.id == id})) + XCTAssertTrue(model.player.waitingForBackground);XCTAssertFalse(model.player.audioPlaybackStarted) + try await Task.sleep(nanoseconds:150_000_000) + XCTAssertGreaterThan(model.backgroundDeck.outputGain,0);XCTAssertLessThan(model.backgroundDeck.outputGain,initial);XCTAssertFalse(model.player.audioPlaybackStarted) + try await Task.sleep(nanoseconds:400_000_000) + XCTAssertTrue(model.player.audioPlaybackStarted);XCTAssertFalse(model.player.waitingForBackground);XCTAssertEqual(model.backgroundDeck.outputGain,0,accuracy:0.001);XCTAssertTrue(model.backgroundDeck.interruptedBySinger) + model.player.stopDeck();model.playBackground();model.pauseBackground() + try await Task.sleep(nanoseconds:150_000_000) + XCTAssertGreaterThan(model.backgroundDeck.outputGain,0);XCTAssertLessThan(model.backgroundDeck.outputGain,initial) + try await Task.sleep(nanoseconds:350_000_000);XCTAssertEqual(model.backgroundDeck.outputGain,0,accuracy:0.001);XCTAssertFalse(model.backgroundDeck.playing) + } + @MainActor func testPauseAndUnloadCancelSingerWaitingForBackgroundFade() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root);defer { model.player.stop();model.backgroundDeck.unload() } + let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let id = try XCTUnwrap(model.library).add(title:"Singer",audio:audio.path,cdg:nil);try model.refresh() + model.studio.automaticBackground = false;model.studio.backgroundPath = audio.path;model.studio.backgroundFadeOut = 0.25 + model.playBackground();try model.play(try XCTUnwrap(model.songs.first{$0.id == id}));model.player.toggle() + try await Task.sleep(nanoseconds:400_000_000) + XCTAssertFalse(model.player.playing);XCTAssertFalse(model.player.audioPlaybackStarted);XCTAssertFalse(model.player.waitingForBackground) + model.player.toggle();XCTAssertTrue(model.player.audioPlaybackStarted) + model.player.toggle();model.playBackground();model.player.toggle();XCTAssertTrue(model.player.waitingForBackground) + model.player.stop();try await Task.sleep(nanoseconds:400_000_000) + XCTAssertFalse(model.player.audioPlaybackStarted);XCTAssertNil(model.player.currentSongID);XCTAssertFalse(model.player.waitingForBackground) + } + +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..8fe979e --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# KJMix Pro 0.6.3 — karaoke hosting studio + +Open **KJMix Pro.xcodeproj**, choose the shared **KJMix Pro** scheme and **My Mac**, then Run. The project contains a real project.pbxproj, SwiftUI macOS app, SQLite library, XCTest target, complete icon assets and build/test/release scripts. No project generator or third-party package is required. + +## Feature status + +| Feature | This build | +| --- | --- | +| Individual singer pitch, tempo and ratings | Saved per singer/song, shared across that singer's venues; automatic save in library and playback settings | +| Singer rotation | Automatic round-robin from pending requests, persistent per-venue Here roster/order/cursor, upcoming preview | +| Manual rotation | Ordered venue queue, multiple performances per singer, reassignment, reorder/remove, unassigned filler/slides | +| Singer web server | Opt-in local HTTP songbook; per-singer invitations, catalogue search, Tonight ordering, favourites, repertoire/history, saved tuning/ratings | +| Fade-out | Automatic timed fade at song endings and manual Fade Out & Stop; audio and shared video fade together | +| ShowScreen Songs | Create/edit timed text slides, colour themes/placeholders; show now, queue, or insert automatically between performances | +| Playlists | Create/rename/delete, add/remove songs, search, sort by title/artist/album/time/rating, save order and sort preference | +| Batch metadata | Multi-select, checked-field changes, find/replace, preview, atomic database updates; optional backed-up file tag writing | +| Online metadata | User-initiated Apple catalogue suggestions, reviewed before applying; recording search rather than CD-disc/fingerprint identification | +| Music stores | All four requested provider profiles, configurable websites/search links, imported JSON catalogue search/sort and browser links | +| Direct store purchasing, subscription playback, DRM cache and purchase-history sync | **Not implemented. Requires provider partner integration documentation/SDKs and application credentials. Website links/catalogue import are not full store integration.** | + +This is not complete kJams parity. Remaining limits are listed below; there is no YouTube integration. + +## Venue → singer → song + +1. Create a venue with File → New Venue or the + beside Current venue. The active venue is remembered. A singer can belong to multiple venues. +2. New Singer + Songs lets you choose a venue and initial songs together. The singer is marked Here and those songs go directly into Tonight and the pending rotation. Manage Venue Singers adds/removes existing singers from that venue; it does not delete their other memberships or repertoire. +3. Expand a singer in the native sidebar to open Tonight, Favourites, History or All Songs. Use their + menu, Add Songs picker, library context menu, or a library drag handle to assign songs. +4. Tonight is a persistent request list per singer/venue. Adding a song also creates one pending performance if needed. Playback consumes that performance but keeps the Tonight request. Use Queue Again to schedule another performance. Reordering played requests does not requeue them. +5. Drag from the library grip beside the song ID onto a singer or Tonight. Reorder Tonight with row drops or arrow buttons. Cross-singer drops copy the request. Media paths are not exported; stale/foreign session references are rejected. +6. Gold favourites and repertoire follow the singer across venues. History records performance starts, not completed songs. + +### Individual settings + +Select a singer, then edit key/tempo/rating in the library or lower settings strip. Changes save immediately for that singer and song. Selecting another singer recalls their independent settings. Tuning applies live only if that same singer/song is playing. With Singer set to None, key/tempo are song defaults and stars are the library rating. Song Info and batch metadata ratings are explicitly library ratings, not personal ratings. The actual playing singer remains associated with playback even while you browse another singer. + +EQ, filter, bypass and master volume remain **global**, never song/singer preferences. They are saved across launches in GlobalMixer.json. HIGH/MID/LOW, filter, output meters and master fader are available in Mixer. The waveform represents source audio before processing; boosts can clip. + +## Automatic and manual rotation + +Open **Rotation**. Choose Automatic · Round Robin and mark participating singers Here. Reorder their roster by dragging or using Earlier/Later in the context menu. Each marked singer takes one turn per cycle; an empty singer keeps their roster position and is skipped until they have pending requests. The last singer called is remembered at each venue. New arrivals join the end; a singer marked away and then Here rejoins at the end. Reset Round starts from the top. + +The preview is computed from pending queue entries, not the persistent already-played portion of Tonight. For four A requests and two B requests the order is A-B-A-B-A-A. Play Next and automatic advancement use this computed order. Unassigned filler entries follow the singer rounds. Unmarked singers are excluded from automatic playback. + +Choose **Manual** to play the queue exactly as arranged. In Queue, move/remove performances and assign a singer or No singer / Filler. Multiple performances per singer are allowed. Changing a queue assignment does not remove another singer's saved Tonight list. Existing queue data survives mode changes; the automatic preview never destructively rewrites the manual queue. + +Auto play next is controlled in Queue. Repeat takes precedence. Open ShowScreens to create a slide, then choose it under Rotation → Between singers to insert it during automatic advancement. The timed slide finishes before the next performance starts. + +### Fade-out and slides + +Rotation includes Fade song endings and a 1–15 second fade duration. The ending fade uses source time adjusted for playback tempo; it is not a silence detector. Fade Out & Stop also works manually from Rotation, the transport stop button, or Command-period. This changes a temporary playback gain and shared video opacity, preserving saved master/EQ. Loading another track cancels a pending fade. Seeking cancels a manual fade. + +ShowScreens have a title, body, footer, indigo/teal/rose theme and 3–300 second duration. Placeholders are {venue}, {singer}, {next}, and {song}. Output is a shared 1920×1080 image used by both monitors. Slides can play now, as unassigned manual queue entries, or between singers; pause/seek/repeat apply. These are text slides, not a general video/animation slide editor. + +## Singer Web + +Choose a current venue, then **Singer Web → Start Server**. Port defaults to 8080; a port from 1024–65535 can be entered before starting. The server is off by default and does not start automatically on launch. + +Use the displayed local IPv4 address or enter this Mac's reachable hostname/IP and port. The Mac and phones must share a reachable network, and macOS/firewall must permit the app's incoming connections. Copy Invitation for each singer and give that singer the link. The token is in the URL fragment rather than an HTTP query; the page clears it from the visible URL and stores it in that browser tab's session storage. Revoke disables a singer's invitation; copying a new one after revocation creates a new token. Switching venue makes invitations for other venues inactive. + +The responsive page provides catalogue search, Tonight add/remove/up/down ordering, favourites, repertoire/history and individual key/tempo/rating controls. Each API request is authenticated to its singer/venue; a supplied different singer ID cannot redirect an action. No audio, filesystem paths, host controls or other singers' history are served. Catalogue results are paged in groups of 50. + +This is **local HTTP**, not a public Internet/TLS hosting service. Invitations grant control of that singer's lists; keep them private. There is no kJams Cue protocol compatibility, public account registration, QR code, Bonjour discovery or remote audio streaming. The host must leave the app running and explicitly start the server. Transport bounds include 16 KB request bodies, 8 KB headers, connection deadlines and concurrency/rate limits. + +## Search, sorting and playlists + +Native library columns sort and resize; horizontal scrolling exposes columns in small windows. Toolbar search supports multiple AND terms, comma/space separation, quoted phrases, title:/s:, artist:/a:, album:, label:, id:, rating:, and = exact matching. A digits-only search matches song ID. Examples: `beat love me`, `artist:beat title:"love me"`, `album:="Live, Now"`. These are the supported fields, not every kJams query operator. + +Playlists have their own create/rename/delete controls and Add Songs picker. Toolbar search filters the active playlist. Choose Saved Order, Title, Artist, Album, Duration or Rating, with ascending/descending order. Sort preferences persist per playlist. Saved order arrows are enabled when no search is active; changing a display sort does not destroy saved order. Add songs from the library context menu too. Continuous show playback uses the venue queue. + +## Metadata and file tags + +Song Info (Command-I) edits title, artist, album, label and library rating in SQLite. Library edits are protected from being overwritten by later imports. + +Command/Shift-select library rows, then choose **Edit N Songs** in the status bar or Batch Edit Selected in the lower-right menu. Only checked fields are changed; unchecked fields remain untouched. Find/replace is case-insensitive and operates on a chosen text field. Review the preview, then apply. Database updates are atomic and reject invalid/empty titles before committing. Singer preferences are separate. + +**Write File Tags** is a separate, explicit action in that menu/context menu. It copies originals into `TagBackups/`, writes a temporary file beside the original, and replaces the original after success. Stop selected playback before writing. MP3 ID3v2.3/v2.4 title/artist/album/publisher tags are supported; unrelated supported frames and audio bytes are retained. Unsupported/extended/unsynchronised ID3 layouts fail without replacing originals. M4A/MP4/M4V/MOV use AVFoundation passthrough export; unsupported containers fail safely. WAV and other formats retain library metadata only. File tag writing does not export personal ratings or rewrite CDG lyrics. Backups are retained; there is no one-click restore UI. Batch file writes are per-file, with successes and issues reported, rather than one all-or-nothing filesystem transaction. + +**Find Metadata Online** searches Apple's public music catalogue for suggestions. Review the recording/version and use the desired result to update library title/artist/album. It does not purchase music, download samples, identify karaoke discs/fingerprints, or silently overwrite files. Requests are paced at least 3.1 seconds apart and cached during the app session; network errors are shown. No Freedb, Gracenote or paid karaoke metadata accounts are integrated. + +## All music-store profiles + +Party Tyme Downloads, Party Tyme Pro Streaming, KaraokeCloud and KaraokeCloud Pro profiles are added once for existing/new libraries. Edits and deletions are respected; Add All Provider Profiles restores missing profiles. Custom stores and optional `{query}` website search templates remain supported. + +Catalogue… supports local JSON import/reload, saved catalogue browsing, search/sort, format/price text and optional provider website/preview links in the browser. `Fixtures/StoreCatalogue.example.json` documents the format using a clearly labelled original example. It is not a downloaded real provider catalogue. Imports require unique IDs, valid HTTP/HTTPS links without credentials, at most 25,000 entries and 10 MB. Purchased DRM-free media can be imported or indexed from a download folder using the normal library tools. + +**Full provider integration remains outstanding.** Direct catalogue/account APIs, one-click purchases, subscription playback, provider DRM/licensed offline cache and purchase-history synchronisation require partner documentation/SDKs and KJMix Pro application credentials for each service. No fake connected/streaming/purchase controls are included, and account passwords/payment details are not collected by KJMix Pro. A consumer subscription alone does not supply an application integration. + +## Local playback, waveform and external drives + +Import MP3/WAV/M4A, same-basename CDG pairs, MP3+G ZIPs or MP4/M4V/MOV with macOS-decodable audio/video. Keep MP3 and CDG together. Direct imports copy managed media; registered locations reference regular files in place. Exact content of the same extension is deduplicated. ZIP entries are validated against traversal/symlinks and extracted media is copied for playback. + +Library Locations recursively scans mounted drives/folders, indexes supported media and prepares cached 4096-bin RMS/frequency waveforms in the background. Cancel keeps completed work. Rescan reuses unchanged entries/caches; Rebuild Waveforms forces analysis. Offline sources retain their catalogue; removing a location keeps songs/media. Reconnect the drive to play referenced files. Results refresh at completion; there is no filesystem watcher/scheduled rescan. + +Waveforms use source bass/mid/treble energy for RGB colours. Hover for exact time, click/drag to scrub, and zoom 1×–32× for precision. Re-analyse is in the waveform menu. Cache keys use source path, size, modification time and analyser version; bad/changed caches rebuild. Background analysis has its own reader and cannot replace a newer track's result. + +The sidebar monitor stays fixed while navigation scrolls. It and the separate Video window share one published CDG/video/slide frame. Local MP4 video is muted and follows the processed audio clock with drift correction; its sound uses the global mixer and independent pitch/tempo engine. The app has one main UI window and optional Video output, with no New Window command. There is no YouTube support. + +## Storage, tests and release + +Data stays under `~/Library/Application Support/KJamsClone/` for continuity. Back up the entire folder: Library.sqlite and its WAL files while running, Media, Waveforms, GlobalMixer.json, StudioSettings.json and TagBackups. Stop the app before copying for a consistent backup. Paths are absolute. Existing libraries migrate additively to schema 9. Host rotation settings, slide definitions, invitations, provider profiles and imported catalogue data live in SQLite. Invitation tokens and user data are excluded from source/app ZIPs. + +```sh +./scripts/build.sh +./scripts/test.sh +./scripts/release.sh +``` + +Release output: `dist/KJMixPro-unsigned-macOS.zip`. The approved icon, complete AppIcon set, high-resolution master and original generation prompt are included under Brand/assets. Bundle/module identifiers remain compatible internally; displayed branding is KJMix Pro. + +**69 XCTest cases passed**, including real loopback HTTP, access isolation/revocation, rotation, independent singer settings, fades, timed slides, batch atomicity, search, MP3/MP4 tag backup/media preservation and all earlier library/playback/waveform/venue/drop regressions. Release builds arm64 and x86_64, tested on Apple Silicon macOS 27.2 with Xcode 27.2/Swift 6.4.2. Deployment target macOS 13; this Xcode's XCTest runtime may require macOS 14+. Intel/older macOS execution is untested. See VERIFICATION.md. + +Original test media: TestTrack.zip/MP3/CDG (20-second synthetic melody and original highlighted lyrics), Demo.* (four-second tone), VideoDemo.mp4 (original colour/motion pattern with synthetic sound). There are no vocals or commercial songs in the distribution. Unsafe.zip is deliberately invalid for tests. Generating test media needs ffmpeg; app playback does not. + +## Remaining limits + +No full commercial-store APIs/DRM, signed/notarised release, CD ripping/burning/disc lookup, vocal-channel routing/removal, crossfade, automatic BPM/key analysis, CDG parity correction/channel selection, or complete kJams compatibility. ShowScreens are static text slides. The server is local HTTP and lacks public Internet account hosting. Native mouse/touch/keyboard flows, hardware display latency, broad commercial-media conformance, large-library/live-show stress, older macOS and Intel execution are not exhaustively validated. + +Direct imports still run on the main thread; large/backward CDG seeks use cancellable background decoding and sparse checkpoints; there is no archive expansion quota. A multi-file direct import is not one filesystem/database transaction and can leave orphan copied media on failure. File tag support is limited as described above. Some AVFoundation metadata/export accessors compile with deprecation warnings. The app is not sandboxed. Design follows native macOS conventions but is not Apple-certified. + +References: [Rotation](https://karaoke.kjams.com/wiki/Rotation), [Manual rotation](https://karaoke.kjams.com/wiki/Rotation/Manual), [Singer server](https://karaoke.kjams.com/wiki/Server), [Music stores](https://karaoke.kjams.com/wiki/Music_Store), [Apple Search API](https://performance-partners.apple.com/search-api), [CD+G Revealed](https://jbum.com/cdg_revealed.html). Implementation and bundled test media are original. + +## Studio deck and background music (0.6.0) + +Singer chevrons are separate click targets; expansion survives navigation. Preferences are cached in bulk and library sorting is reused between sections. Rotation has an on-stage card, upcoming queue, attendance roster, New/Existing Singer menu, per-singer Add Songs buttons, and Tonight search with Queued / Queue Again status. Adding to Tonight marks the singer Here; drag from library to a rotation singer also adds a request. + +Choose a background audio file in the bottom strip. **Auto** starts it between singers, pauses it before singer audio starts, and resumes it after a singer finishes, stops or pauses. Immediate next-singer playback takes priority, with a short handoff delay to avoid a background burst. Manual background Pause/Stop turns Auto off; enable Auto to restore automatic handoffs. A restored track does not autoplay at launch. Background loops and trim are configurable; EQ and master remain global. + +The waveform deck offers play/pause, stop with track retained, jog, Option-scroll jog, cue, four hot cues, A/B loop and fade/unload. Cues and loops last for the loaded deck session. There is no beat sync or vinyl scratching DSP. Settings (sidebar, gear or Command-comma) configures playback, jog, graphics offset, hosting, background and default server port. StudioSettings.json stores these options. Minimum main window: 1060 × 760; default: 1320 × 900. + +## Rotation controls (0.6.1) + +Use **Start Rotation** to begin continuous playback or resume the current rotation performance; **Pause**, **Stop** and **Next Singer** are beside it. Starting enables Auto advance. Stop rewinds the current performance and turns automatic advancement off; Start can play it again. An empty/away roster produces an explanation rather than silently doing nothing. + +Reorder singers with the dedicated grip or visible up/down arrows. Singer drops and library-song drops share a single validated dispatcher, so a singer drag cannot be consumed by the song-assignment target. Reordering saves the venue roster and resets the round cursor so Upcoming reflects the chosen order; in Manual Queue it also groups pending performances in the singer order while preserving each singer’s request order. + +Click **Songs** beside a singer or **Add Songs** to open the inline searchable browser. Choose a singer and click **Add to Tonight**; this also marks them Here and creates a pending request. Switch back to **Upcoming** to see the performance order. Both views have their own scrolling space at the minimum window size. Dragging a song onto a singer adds it to that singer’s Tonight. + +Background music now has an elapsed/total time seek slider in the bottom strip. Scrub while playing to audition a point, or while paused to choose where the next Play resumes. Seeking while paused for a singer does not start background audio. The selected position lasts for the loaded track session; Stop rewinds it and track reload starts at zero. + +Background fade times are available in Settings → Background Music: separate Fade in and Fade out sliders (0–15 seconds, saved automatically). Zero disables the fade. Background Play fades in; background Pause/Stop fades out. Singer start fades background out first, then begins singer audio after it reaches silence. Scrubbing cancels an active fade; paused scrubbing stays silent. + +In 0.6.3 the configured background Fade out also applies when starting/resuming a singer. The deck shows “Fading background…” during the wait; singer audio and its source clock start only after the fade. Pause, Stop, seek or replacing the loaded track cancels a pending singer start. A zero-second Fade out retains an immediate handoff. diff --git a/VERIFICATION.md b/VERIFICATION.md new file mode 100644 index 0000000..37924d2 --- /dev/null +++ b/VERIFICATION.md @@ -0,0 +1,19 @@ +# KJMix Pro 0.6.3 (build 14) verification + +Verified on 3 November 2026. + +- 69 XCTest cases passed, zero failures. See verification/xcode-test.log. +- Coverage includes actual mouse clicks on singer Songs, Add to Tonight, reorder arrow and Start Rotation; both singer and song NSItemProviders routed through the unified drop handler; persisted manual/smart order; start/pause/resume/stop; singer chevron and navigation via actual mouse events in an isolated native window; independent singer preferences; SQLite persistence; rotation drag validation; atomic singer-plus-Tonight creation; automatic next-singer playback at a real audio end; automatic background resume after the queue empties; manual background override; saved settings restored without launch autoplay; cue/jog/loop/stop; rapid CDG seeks; and prior media, server, import, tags and library regressions. +- Actual SwiftUI views rendered with isolated original demo data at 1320 × 900 and 1060 × 760. Rotation, settings and library screenshots accompany this report. +- Release builds both arm64 and x86_64. Source ZIP includes canonical KJMix Pro.xcodeproj/project.pbxproj and shared scheme, source, tests, executable scripts, icon assets and original test media only. +- ZIP CRC checks, extracted file hashes and extracted Xcode project listing are checked during packaging. Personal media, library databases, invitations, bookmarks and user settings are excluded. + +This is an unsigned, unnotarised development release. Intel/older macOS runtime, broad commercial media, hardware/display latency, large-library show stress and every native drag/keyboard interaction are not exhaustively validated. Music-store profiles/catalogue imports are not provider purchase or subscription integrations. See README.md for feature limits. + +The complete operating-system drag gesture is not automated end to end. Drop payload delivery and ordering are tested with real NSItemProviders, and visible reorder arrows provide an independently mouse-tested alternative. + +Background seeking is tested while playing, paused and paused for a singer, including negative/end/nonfinite positions and resuming at the chosen position. + +Configurable background fade times persist; tests inspect the real mixer output gain during fade-in and after fade-out, and verify cancellation when scrubbing or starting singer playback. + +Fade-out handoff regression checks inspect actual mixer gain while it decreases, assert the singer audio node has not started during the fade and starts after silence, and verify Pause/unload cancel the pending singer start. Manual background fade-out also has an intermediate gain check. diff --git a/backups/before-classic-library-20261103-085620/KJamsClone/Importer.swift b/backups/before-classic-library-20261103-085620/KJamsClone/Importer.swift new file mode 100644 index 0000000..0b023d1 --- /dev/null +++ b/backups/before-classic-library-20261103-085620/KJamsClone/Importer.swift @@ -0,0 +1,64 @@ +import Foundation +import CryptoKit +import AVFoundation + +struct Importer { + let media: URL + func run(_ urls: [URL], library: Library) throws { + try FileManager.default.createDirectory(at: media, withIntermediateDirectories: true) + var files = urls + var temporary = [URL]() + defer { for url in temporary { try? FileManager.default.removeItem(at: url) } } + for zip in urls where zip.pathExtension.lowercased() == "zip" { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + temporary.append(folder) + // Validate names before extraction; reject path traversal and symlinks afterwards. + let details = try process(["-Z", "-l", zip.path]) + guard !details.split(separator: "\n").contains(where: { $0.hasPrefix("l") }) else { throw LibraryError.message("ZIP symlinks are unsupported") } + let list = try process(["-Z1", zip.path]) + let names = list.split(separator: "\n").map(String.init) + guard !names.isEmpty, names.allSatisfy({ !$0.hasPrefix("/") && !$0.split(separator: "/").contains("..") && !$0.contains("\\") }) else { throw LibraryError.message("Unsafe or empty ZIP") } + _ = try process(["-q", zip.path, "-d", folder.path]) + let extracted = FileManager.default.enumerator(at: folder, includingPropertiesForKeys: [.isSymbolicLinkKey,.isRegularFileKey])! + for case let url as URL in extracted { + let values = try url.resourceValues(forKeys: [.isSymbolicLinkKey,.isRegularFileKey]) + guard values.isSymbolicLink != true else { throw LibraryError.message("ZIP symlinks are unsupported") } + if values.isRegularFile == true { files.append(url) } + } + } + for cdg in files.filter({ $0.pathExtension.lowercased() == "cdg" }) { + let siblings = try FileManager.default.contentsOfDirectory(at:cdg.deletingLastPathComponent(),includingPropertiesForKeys:nil) + if let audio = siblings.first(where: { ["mp3","wav","m4a"].contains($0.pathExtension.lowercased()) && $0.deletingPathExtension().lastPathComponent.caseInsensitiveCompare(cdg.deletingPathExtension().lastPathComponent) == .orderedSame }) { files.append(audio) } + } + var imported = 0 + for audio in Set(files) where ["mp3","wav","m4a"].contains(audio.pathExtension.lowercased()) { + let siblings = try FileManager.default.contentsOfDirectory(at: audio.deletingLastPathComponent(), includingPropertiesForKeys: nil) + let cdg = siblings.first { $0.pathExtension.lowercased() == "cdg" && $0.deletingPathExtension().lastPathComponent.caseInsensitiveCompare(audio.deletingPathExtension().lastPathComponent) == .orderedSame } + let hash = SHA256.hash(data: try Data(contentsOf: audio)).map { String(format: "%02x", $0) }.joined() + let dest = media.appendingPathComponent(hash + "." + audio.pathExtension.lowercased()) + if !FileManager.default.fileExists(atPath: dest.path) { try FileManager.default.copyItem(at: audio, to: dest) } + var graphics: String? + if let cdg { + let target = media.appendingPathComponent(hash + ".cdg") + if !FileManager.default.fileExists(atPath: target.path) { try FileManager.default.copyItem(at: cdg, to: target) } + graphics = target.path + } + imported += 1 + let metadata = AVURLAsset(url:audio).commonMetadata + func tag(_ key: AVMetadataKey) -> String { metadata.first { $0.commonKey == key }?.stringValue ?? "" } + let taggedTitle = tag(.commonKeyTitle) + try library.add(title:taggedTitle.isEmpty ? audio.deletingPathExtension().lastPathComponent : taggedTitle,audio:dest.path,cdg:graphics,artist:tag(.commonKeyArtist),album:tag(.commonKeyAlbumName),label:tag(.commonKeyPublisher)) + } + guard imported > 0 else { throw LibraryError.message("No audio found. Keep the MP3 and matching CDG together, or import an MP3+G ZIP.") } + } + private func process(_ arguments: [String]) throws -> String { + let task = Process(); task.executableURL = URL(fileURLWithPath: "/usr/bin/unzip"); task.arguments = arguments + let out = Pipe(); task.standardOutput = out + let err = FileHandle.nullDevice; task.standardError = err + try task.run() + let bytes = out.fileHandleForReading.readDataToEndOfFile(); task.waitUntilExit() + guard task.terminationStatus == 0 else { throw LibraryError.message("Cannot read ZIP archive") } + return String(decoding: bytes, as: UTF8.self) + } +} diff --git a/backups/before-classic-library-20261103-085620/KJamsClone/KJamsCloneApp.swift b/backups/before-classic-library-20261103-085620/KJamsClone/KJamsCloneApp.swift new file mode 100644 index 0000000..4678872 --- /dev/null +++ b/backups/before-classic-library-20261103-085620/KJamsClone/KJamsCloneApp.swift @@ -0,0 +1,940 @@ +import SwiftUI +import UniformTypeIdentifiers + +enum EntityKind: String, Identifiable { + case singer, venue + var id: String { rawValue } + var title: String { self == .singer ? "Singer" : "Venue" } + var table: String { self == .singer ? "singers" : "venues" } +} + +@MainActor final class AppModel: ObservableObject { + @Published var songs = [Song]() + @Published var singers = [PersonPlace]() + @Published var singerSongIDs = [Int64:[Int64]]() + var newSingerSongs = Set() + @Published var venues = [PersonPlace]() + @Published var venueSingers = [PersonPlace]() + @Published var playlists = [PersonPlace]() + @Published var queue = [QueueEntry]() + @Published var history = [Performance]() + @Published private(set) var currentVenue: Int64? + @Published var selectedSinger: Int64? + @Published var autoAdvance = true + @Published var error: String? + @Published var creating: EntityKind? + let player = Player() + private(set) var library: Library? + let root: URL + var currentVenueName: String { venues.first { $0.id == currentVenue }?.name ?? "No venue selected" } + init(storageURL: URL? = nil) { + root = storageURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("KJamsClone") + perform { + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + library = try Library(url:root.appendingPathComponent("Library.sqlite")); try refresh() + } + player.onFinished = { [weak self] in + guard let self, self.autoAdvance, !self.queue.isEmpty else { return } + self.playQueueNext() + } + } + func perform(_ work: () throws -> Void) { do { try work() } catch { self.error = error.localizedDescription } } + func refresh() throws { + guard let library else { throw LibraryError.message("Library unavailable") } + songs = try library.songs(); singers = try library.entities("singers"); venues = try library.entities("venues") + singerSongIDs = try library.singerSongIDs() + playlists = try library.entities("playlists"); currentVenue = try library.currentVenue() + venueSingers = try currentVenue.map { try library.members(of:$0) } ?? singers + if let selectedSinger, !venueSingers.contains(where: { $0.id == selectedSinger }) { self.selectedSinger = nil } + queue = try library.queue(venue:currentVenue); history = try library.history(venue:currentVenue) + } + func chooseVenue(_ id: Int64?) { + perform { + guard let library else { throw LibraryError.message("Library unavailable") } + try library.selectVenue(id); selectedSinger = nil; try refresh() + } + } + func membership(singer: Int64, included: Bool) { + perform { + guard let library, let currentVenue else { throw LibraryError.message("Select a current venue first") } + try library.setMembership(singer:singer,venue:currentVenue,included:included); try refresh() + } + } + @discardableResult func createEntity(_ kind: EntityKind, name: String) throws -> Int64 { + guard let library else { throw LibraryError.message("Library unavailable") } + let trimmed = name.trimmingCharacters(in:.whitespacesAndNewlines) + try library.addEntity(kind.table,name:trimmed) + guard let entity = try library.entities(kind.table).first(where: { $0.name == trimmed }) else { throw LibraryError.message("Could not load new entry") } + if kind == .singer { + if let currentVenue { try library.setMembership(singer:entity.id,venue:currentVenue,included:true) } + selectedSinger = entity.id + } else { try library.selectVenue(entity.id); selectedSinger = nil } + try refresh(); return entity.id + } + func beginSinger(song: Int64? = nil) { newSingerSongs = Set(song.map { [$0] } ?? []); creating = .singer } + func songs(for singer: Int64) -> [Song] { + (singerSongIDs[singer] ?? []).compactMap { id in songs.first { $0.id == id } } + } + func assign(_ song: Int64, to singer: Int64, included: Bool = true) { + perform { + guard let library else { throw LibraryError.message("Library unavailable") } + if included { try library.assignSong(song,singer:singer) } else { try library.removeSingerSong(song,singer:singer) } + try refresh() + } + } + func createSinger(name: String, venue: Int64, songs: Set) throws -> Int64 { + guard let library else { throw LibraryError.message("Library unavailable") } + guard venues.contains(where: { $0.id == venue }) else { throw LibraryError.message("Choose a venue first") } + let name = name.trimmingCharacters(in:.whitespacesAndNewlines) + try library.execute("BEGIN IMMEDIATE") + let id: Int64 + do { + try library.addEntity("singers",name:name) + guard let singer = try library.entities("singers").first(where: { $0.name == name }) else { throw LibraryError.message("Could not load singer") } + id = singer.id + try library.setMembership(singer:id,venue:venue,included:true) + for song in songs { try library.assignSong(song,singer:id) } + try library.selectVenue(venue);try library.execute("COMMIT") + } catch { try? library.execute("ROLLBACK");throw error } + try refresh();selectedSinger = id;return id + } + func rename(_ table: String, id: Int64, name: String) { perform { try library?.rename(table,id:id,name:name); try refresh() } } + func delete(_ table: String, id: Int64) { perform { try library?.deleteEntity(table,id:id); try refresh() } } + func preference(_ song: Int64) -> SongPreference { + guard let library, let selectedSinger else { return .init() } + return (try? library.preference(song:song,singer:selectedSinger)) ?? .init() + } + func savePreference(song: Int64, value: SongPreference) { + perform { + guard let library, let selectedSinger else { throw LibraryError.message("Select a singer to save their song preferences") } + try library.savePreference(song:song,singer:selectedSinger,value:value); try refresh() + } + } + func play(_ song: Song, singer: Int64? = nil) throws { + guard let library else { throw LibraryError.message("Library unavailable") } + let singer = singer ?? selectedSinger + if let singer, let currentVenue, try !library.members(of:currentVenue).contains(where: { $0.id == singer }) { throw LibraryError.message("This singer is not in the current venue") } + let preference = try singer.map { try library.preference(song:song.id,singer:$0) } ?? SongPreference() + try player.load(song,preference:preference) + do { try library.record(song.id,singer:singer,venue:currentVenue); selectedSinger = singer; try refresh() } + catch { player.stop(); throw error } + } + func enqueue(_ song: Int64) { perform { try library?.enqueue(song:song,singer:selectedSinger,venue:currentVenue); try refresh() } } + func playQueueNext() { + perform { + guard let row = queue.first, let song = songs.first(where: { $0.id == row.songID }) else { return } + // Explicitly clear selection for unassigned queue entries. + selectedSinger = row.singerID + try play(song,singer:row.singerID); try library?.removeQueue(row.id); try refresh() + } + } + func moveQueue(_ id: Int64, by offset: Int) { perform { if let currentVenue { try library?.moveQueue(id,venue:currentVenue,by:offset); try refresh() } } } + func removeQueue(_ id: Int64) { perform { try library?.removeQueue(id); try refresh() } } + func createPlaylist(_ name: String) { perform { try library?.addEntity("playlists",name:name); try refresh() } } + func addToPlaylist(_ playlist: Int64, song: Int64) { perform { try library?.addToPlaylist(playlist,song:song); try refresh() } } + func importFiles(_ urls: [URL]) { perform { guard let library else { throw LibraryError.message("Library unavailable") }; try Importer(media:root.appendingPathComponent("Media")).run(urls,library:library); try refresh() } } +} +@main struct KJamsCloneApp: App { + @StateObject private var model = AppModel() + var body: some Scene { + Window("KJamsClone", id: "main") { + LibraryView(model: model).frame(minWidth: 1060, minHeight: 680).preferredColorScheme(.dark) + }.defaultSize(width: 1260, height: 840) + .commands { + CommandGroup(replacing: .newItem) { + Button("New Singer…") { model.beginSinger() }.keyboardShortcut("n", modifiers: [.command, .shift]) + Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option]) + } + } + Window("Video", id: "lyrics") { + LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark) + }.defaultSize(width: 600, height: 400) + } +} +private enum Classic { + static let frame = Color(red: 0.15, green: 0.15, blue: 0.15) + static let navy = Color(red: 0.008, green: 0.065, blue: 0.16) + static let blue = Color(red: 0.015, green: 0.17, blue: 0.42) + static let header = Color(white: 0.115) + static let line = Color(white: 0.42) +} +struct LyricsView: View { + @ObservedObject var player: Player + var body: some View { + ZStack { + Color.black + if let image = player.image { + Image(decorative: image, scale: 1).interpolation(.none).resizable().aspectRatio(1.5, contentMode: .fit) + } + } + } +} +/// This is another view of the same published CGImage used by the Video window. +/// It owns no decoder, audio player or clock, so seeking/pause/tempo stay identical. +private struct SidebarVideoMonitor: View { + let player: Player + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Label("Video Monitor", systemImage: "tv") + .font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7)) + LyricsView(player: player) + .aspectRatio(1.5, contentMode: .fit) + .clipped() + .overlay(Rectangle().strokeBorder(.white.opacity(0.25), lineWidth: 1)) + .accessibilityLabel("Video monitor, synchronized with the Video window") + }.frame(maxWidth: .infinity) + } +} +private struct SilverSquareStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label.foregroundStyle(Color(white: 0.25)).padding(.horizontal, 6).padding(.vertical, 3) + .background(LinearGradient(colors: [Color(white: 0.95), Color(white: 0.64)], startPoint: .top, endPoint: .bottom), in: RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Color(white: 0.45), lineWidth: 1.5)) + .overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(.white.opacity(0.7)).padding(3)) + .opacity(configuration.isPressed ? 0.7 : 1) + } +} +private struct SilverButton: View { + let symbol: String + let label: String + var size: CGFloat = 40 + var active = false + var enabled = true + let action: () -> Void + var body: some View { + Button(action: action) { + Image(systemName: symbol).font(.system(size: size * 0.41, weight: .semibold)) + .foregroundStyle(active ? Color.blue : Color(white: 0.24)) + .frame(width: size, height: size) + .background(LinearGradient(colors: [.white, Color(white: 0.85), Color(white: 0.63)], startPoint: .top, endPoint: .bottom), in: Circle()) + .overlay(Circle().strokeBorder(Color(white: 0.45), lineWidth: 1.5)) + .overlay(Circle().strokeBorder(.white.opacity(0.65), lineWidth: 1).padding(3)) + .shadow(color: .black.opacity(0.7), radius: 1, y: 1) + }.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.72) + .help(label).accessibilityLabel(label) + } +} +struct LibraryView: View { + @ObservedObject var model: AppModel + @State private var selected: Int64? + @State private var search = "" + @State private var name = "" + @State private var importing = false + @State private var source = "Library" + @State private var singersExpanded = true + @State private var expandedSingers = Set() + @State private var songPickerSinger: PersonPlace? + @Environment(\.openWindow) private var openWindow + private var visibleSongs: [Song] { model.songs.filter { search.isEmpty || ($0.title+" "+$0.artist+" "+$0.album).localizedCaseInsensitiveContains(search) } } + private var selectedSong: Song? { model.songs.first { $0.id == selected } } + var body: some View { + VStack(spacing: 0) { + TransportView(player: model.player, search: $search, hasSelection: selectedSong != nil, + play: playSelected, next: playNext, display: { openWindow(id: "lyrics") }) + .padding(.horizontal, 18).padding(.top, 18).padding(.bottom, 22) + HSplitView { + sidebar.frame(minWidth: 190, idealWidth: 220, maxWidth: 240) + VStack(spacing: 12) { + if source == "Library" { songTable } + else if source == "Queue" { queuePane } + else if source == "History" { historyPane } + else if source == "Playlists" { playlistPane } + else if source == "Singer Songs" { singerSongPane } + else { directory } + detailPanel.frame(height: 135) + }.padding(.leading, 12).frame(minWidth: 780) + }.padding(.horizontal, 18) + footer.padding(.horizontal, 18).padding(.vertical, 12) + } + .background(Classic.frame) + .sheet(item: $model.creating) { kind in + AddEntitySheet(model: model, kind: kind) { id in + if kind == .singer { model.selectedSinger = id;expandedSingers.insert(id);singersExpanded = true;source = "Singer Songs" } else { model.chooseVenue(id) } + } + } + .sheet(item:$songPickerSinger) { singer in SingerSongsSheet(model:model,singer:singer) } + .fileImporter(isPresented: $importing, allowedContentTypes: [.audio, .zip, UTType(filenameExtension: "cdg") ?? .data], allowsMultipleSelection: true) { result in + switch result { + case .success(let urls): model.importFiles(urls) + case .failure(let error): model.error = error.localizedDescription + } + } + .alert("KJamsClone", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { + Button("OK") { model.error = nil } + } message: { Text(model.error ?? "") } + } + private func sourceCount(_ source: String) -> Int { + switch source { + case "Library": return model.songs.count + case "Singers": return model.venueSingers.count + case "Venues": return model.venues.count + case "Queue": return model.queue.count + case "History": return model.history.count + default: return model.playlists.count + } + } + private var sidebar: some View { + VStack(alignment:.leading,spacing:0) { + Text("Source").font(.system(size:15,weight:.semibold)).padding(.horizontal,10).frame(height:31) + .frame(maxWidth:.infinity,alignment:.leading).background(Classic.header) + ScrollView { + VStack(spacing:0) { + sourceRow("Library",icon:"music.note.house.fill",color:.yellow) + HStack(spacing:5) { + Button { singersExpanded.toggle() } label: { Image(systemName:singersExpanded ? "chevron.down" : "chevron.right").font(.system(size:10)).frame(width:12) }.buttonStyle(.plain).accessibilityLabel("Expand singers") + Button { source = "Singers" } label: { HStack { Image(systemName:"person.2.fill").foregroundStyle(.green);Text("Singers");Spacer();Text("\(model.venueSingers.count)").font(.caption).foregroundStyle(.secondary) } }.buttonStyle(.plain) + Button { model.beginSinger() } label: { Image(systemName:"plus.circle.fill").foregroundStyle(.green) }.buttonStyle(.plain).help("New singer and songs") + }.padding(.horizontal,10).frame(height:34).background(source == "Singers" ? Color.blue.opacity(0.65) : .clear) + if singersExpanded { + Text(model.currentVenueName).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1).padding(.leading,30).frame(maxWidth:.infinity,alignment:.leading).padding(.vertical,4) + ForEach(model.venueSingers) { singer in sidebarSinger(singer) } + if model.venueSingers.isEmpty { Text("Add a singer with +").font(.caption).foregroundStyle(.secondary).padding(8) } + } + sourceRow("Venues",icon:"building.2.fill",color:.cyan) + sourceRow("Queue",icon:"list.number",color:.orange) + sourceRow("History",icon:"clock.arrow.circlepath",color:.purple) + sourceRow("Playlists",icon:"music.note.list",color:.mint) + } + } + SidebarVideoMonitor(player:model.player).padding(10) + }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line,lineWidth:1)) + } + private func sourceRow(_ title: String, icon: String, color: Color) -> some View { + Button { source = title } label: { + HStack(spacing:10) { + Image(systemName:icon).foregroundStyle(color).frame(width:22) + Text(title).font(.system(size:16,weight:.medium));Spacer() + Text("\(sourceCount(title))").font(.system(size:11)).foregroundStyle(.white.opacity(0.6)) + }.padding(.horizontal,12).frame(height:34).contentShape(Rectangle()) + .background(source == title ? Color(red:0,green:0.36,blue:1) : .clear) + }.buttonStyle(.plain) + } + private func sidebarSinger(_ singer: PersonPlace) -> some View { + VStack(alignment:.leading,spacing:0) { + HStack(spacing:5) { + Button { + if expandedSingers.contains(singer.id) { expandedSingers.remove(singer.id) } else { expandedSingers.insert(singer.id) } + } label: { Image(systemName:expandedSingers.contains(singer.id) ? "chevron.down" : "chevron.right").font(.system(size:9)).frame(width:12) }.buttonStyle(.plain).accessibilityLabel("Expand \(singer.name)’s songs") + Button { model.selectedSinger = singer.id;source = "Singer Songs" } label: { + HStack(spacing:5) { Text(singer.name).lineLimit(1);Spacer();Text("\(model.singerSongIDs[singer.id]?.count ?? 0)").font(.system(size:10)).foregroundStyle(.secondary) } + }.buttonStyle(.plain) + Button { model.selectedSinger = singer.id;songPickerSinger = singer;expandedSingers.insert(singer.id) } label: { Image(systemName:"plus").font(.system(size:10,weight:.bold)) }.buttonStyle(.plain).help("Add songs to \(singer.name)") + }.font(.system(size:13,weight:.medium)).padding(.leading,25).padding(.trailing,12).frame(height:29) + .background(model.selectedSinger == singer.id && source == "Singer Songs" ? Color.blue.opacity(0.25) : .clear) + if expandedSingers.contains(singer.id) { + ForEach(model.songs(for:singer.id)) { song in + Button { model.selectedSinger = singer.id;selected = song.id;source = "Singer Songs" } label: { + Label(song.title,systemImage:"music.note").font(.system(size:11)).lineLimit(1).frame(maxWidth:.infinity,alignment:.leading).padding(.leading,44).padding(.trailing,8).frame(height:25) + }.buttonStyle(.plain) + .contextMenu { + Button("Play for \(singer.name)") { model.selectedSinger = singer.id;model.perform { try model.play(song,singer:singer.id) } } + Button("Queue for \(singer.name)") { model.selectedSinger = singer.id;model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Button("Remove from Singer",role:.destructive) { model.assign(song.id,to:singer.id,included:false) } + } + } + if model.songs(for:singer.id).isEmpty { Text("No songs · use +").font(.system(size:10)).foregroundStyle(.secondary).padding(.leading,44).padding(.vertical,5) } + } + } + } + private var singerSongPane: some View { + VStack(alignment:.leading,spacing:12) { + if let singer = model.singers.first(where:{ $0.id == model.selectedSinger }) { + HStack { Text("\(singer.name)’s Songs").font(.headline);Spacer();Button("Add Songs…") { songPickerSinger = singer } } + Text("At \(model.currentVenueName). Song assignments follow this singer across venues.").font(.caption).foregroundStyle(.secondary) + ScrollView { + VStack(spacing:10) { + ForEach(model.songs(for:singer.id)) { song in + HStack { + Text(song.title).lineLimit(1);Spacer() + Button("Play") { model.selectedSinger = singer.id;model.perform { try model.play(song,singer:singer.id) } } + Button("Queue") { model.selectedSinger = singer.id;model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Button("Remove") { model.assign(song.id,to:singer.id,included:false) } + }.padding(8).background(.white.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = song.id } + } + if model.songs(for:singer.id).isEmpty { Text("Add library songs with Add Songs… or the + beside this singer in the sidebar.").foregroundStyle(.secondary).padding(.top,25) } + } + } + } else { Text("Choose a singer from the sidebar.").foregroundStyle(.secondary);Spacer() } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + private var songTable: some View { + GeometryReader { geometry in + ScrollView(.horizontal) { + VStack(spacing: 0) { + tableHeader(width: max(geometry.size.width, 790)) + ScrollView(.vertical) { + LazyVStack(spacing: 0) { + ForEach(visibleSongs) { song in songRow(song, width: max(geometry.size.width, 790)) } + }.frame(maxWidth: .infinity, alignment: .topLeading) + } + }.frame(width: max(geometry.size.width, 790), height: geometry.size.height) + } + }.background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) + } + private func widths(_ total: CGFloat) -> [CGFloat] { + [42, max(200, total - 598), 100, 100, 76, 86, 72, 122] + } + private func tableHeader(width: CGFloat) -> some View { + let sizes = widths(width) + return HStack(spacing: 0) { + ForEach(Array(["PLI", "Song Name", "Artist", "Album", "Label", "Key Adjust", "Tempo", "Rating"].enumerated()), id: \.offset) { index, title in + Text(title).font(.system(size: 13, weight: .medium)).lineLimit(1) + .padding(.leading, 7).frame(width: sizes[index], height: 30, alignment: .leading) + .overlay(alignment: .trailing) { Rectangle().fill(Classic.line).frame(width: 1).padding(.vertical, 4) } + } + }.foregroundStyle(.white).background(Classic.header) + .overlay(alignment: .bottom) { Rectangle().fill(Color(white: 0.65)).frame(height: 1) } + } + private func cellText(_ song: Song, column: Int) -> String { + switch column { + case 2: return song.artist.isEmpty ? "—" : song.artist + case 3: return song.album.isEmpty ? "—" : song.album + case 4: return song.label.isEmpty ? (song.cdg == nil ? "Audio" : URL(fileURLWithPath:song.audio).pathExtension.uppercased()+"+G") : song.label + case 5: return String(format:"%+.0f",model.preference(song.id).pitch) + default: return String(format:"%.0f%%",model.preference(song.id).tempo*100) + } + } + private func songRow(_ song: Song, width: CGFloat) -> some View { + let sizes = widths(width) + return HStack(spacing: 0) { + Image(systemName: song.cdg == nil ? "music.note" : "music.note.tv") + .foregroundStyle(.white.opacity(0.75)).frame(width: sizes[0]) + Text(song.title).lineLimit(1).padding(.leading, 7).frame(width: sizes[1], alignment: .leading) + ForEach(2..<7) { index in + Text(cellText(song, column: index)) + .foregroundStyle(.white.opacity(0.5)).padding(.leading, 7).frame(width: sizes[index], alignment: .leading) + } + Menu { + ForEach(0..<6) { value in + Button(value == 0 ? "Unrated" : String(repeating: "★", count: value)) { + model.perform { try model.library?.rate(song.id, value); try model.refresh() } + } + } + } label: { + Text(song.rating == 0 ? "☆☆☆☆☆" : String(repeating: "★", count: song.rating) + String(repeating: "☆", count: 5 - song.rating)) + .font(.system(size: 13)).foregroundStyle(song.rating == 0 ? Color.gray : Color.yellow) + }.menuStyle(.borderlessButton).frame(width: sizes[7] - 14).padding(.horizontal, 7).help("Set song rating") + }.font(.system(size: 13)).foregroundStyle(.white).frame(height: 30) + .background(selected == song.id ? Color(red: 0, green: 0.28, blue: 0.72) : .clear) + .contentShape(Rectangle()).onTapGesture(count: 2) { selected = song.id; playSelected() } + .onTapGesture { selected = song.id } + .contextMenu { + Button("Play") { selected = song.id; playSelected() } + Menu("Add to Singer") { + ForEach(model.venueSingers) { singer in Button(singer.name) { model.assign(song.id,to:singer.id);model.selectedSinger = singer.id;expandedSingers.insert(singer.id) } } + Divider();Button("New Singer + This Song…") { model.beginSinger(song:song.id) } + } + Button("Add to Current Venue Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Menu("Add to Playlist") { ForEach(model.playlists) { playlist in Button(playlist.name) { model.addToPlaylist(playlist.id,song:song.id) } } } + } + } + @State private var managingRoster = false + @State private var rosterNewName = "" + @State private var editingEntity: PersonPlace? + @State private var renameText = "" + @State private var pendingDelete: PersonPlace? + private var directory: some View { + VStack(alignment:.leading,spacing:0) { + HStack { + Text(source == "Singers" ? "Singers · \(model.currentVenueName)" : "Venues").font(.headline) + Spacer() + if source == "Singers" { Button("Manage Venue Singers…") { managingRoster = true }.disabled(model.currentVenue == nil) } + Button(source == "Singers" ? "New Singer…" : "New Venue…") { if source == "Singers" { model.beginSinger() } else { model.creating = .venue } } + }.padding(12).background(Classic.header) + ScrollView { + VStack(alignment:.leading,spacing:0) { + if source == "Singers" && model.venueSingers.isEmpty { + Text("No singers at this venue. Add a singer or use Manage Venue Singers to include existing singers.").foregroundStyle(.secondary).padding() + } + ForEach(source == "Singers" ? model.venueSingers : model.venues) { entity in + HStack { + Button { + if source == "Singers" { model.selectedSinger = entity.id } else { model.chooseVenue(entity.id) } + } label: { + HStack { + Image(systemName:source == "Singers" ? "person.fill" : "building.2.fill") + Text(entity.name); Spacer() + if entity.id == (source == "Singers" ? model.selectedSinger : model.currentVenue) { + Text(source == "Singers" ? "Selected" : "Current venue").font(.caption).foregroundStyle(.cyan) + Image(systemName:"checkmark.circle.fill").foregroundStyle(.cyan) + } + }.contentShape(Rectangle()) + }.buttonStyle(.plain) + Button("Rename") { renameText = entity.name; editingEntity = entity } + Button("Delete",role:.destructive) { pendingDelete = entity } + }.padding(12) + } + } + } + HStack { + TextField(source == "Singers" ? "Singer name" : "Venue name",text:$name).textFieldStyle(.roundedBorder).onSubmit(addEntity) + Button(source == "Singers" ? "Add Singer" : "Add Venue",action:addEntity).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + }.padding(12) + }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + .sheet(isPresented:$managingRoster) { rosterSheet } + .sheet(item:$editingEntity) { entity in + VStack(alignment:.leading,spacing:16) { + Text("Rename \(source == "Singers" ? "Singer" : "Venue")").font(.headline) + TextField("Name",text:$renameText).textFieldStyle(.roundedBorder) + HStack { Button("Cancel") { editingEntity = nil }; Spacer(); Button("Save") { model.rename(source == "Singers" ? "singers" : "venues",id:entity.id,name:renameText); editingEntity = nil }.disabled(renameText.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) } + }.padding(24).frame(width:360) + } + .confirmationDialog("Delete \(pendingDelete?.name ?? "entry")? Historical plays are kept. Venue memberships and queued entries for it will be removed.",isPresented:Binding(get:{ pendingDelete != nil },set:{ if !$0 { pendingDelete = nil } })) { + Button("Delete",role:.destructive) { if let entity = pendingDelete { model.delete(source == "Singers" ? "singers" : "venues",id:entity.id) }; pendingDelete = nil } + } + } + private var rosterSheet: some View { + VStack(alignment:.leading,spacing:16) { + Text("Singers at \(model.currentVenueName)").font(.title2.bold()) + Text("A singer can belong to any number of venues. Unchecking removes them from this venue only.").foregroundStyle(.secondary) + ScrollView { + VStack(alignment:.leading,spacing:12) { + ForEach(model.singers) { singer in + Toggle(singer.name,isOn:Binding(get:{ model.venueSingers.contains { $0.id == singer.id } },set:{ model.membership(singer:singer.id,included:$0) })) + } + }.frame(maxWidth:.infinity,alignment:.leading) + }.frame(minHeight:120,maxHeight:280) + HStack { + TextField("New singer name",text:$rosterNewName).textFieldStyle(.roundedBorder) + Button("Add") { model.perform { _ = try model.createEntity(.singer,name:rosterNewName); rosterNewName = "" } }.disabled(rosterNewName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + Spacer(); Button("Done") { managingRoster = false }.keyboardShortcut(.defaultAction) + } + }.padding(24).frame(width:460) + } + private var detailPanel: some View { + VStack(alignment:.leading,spacing:9) { + HStack(spacing:10) { + Picker("Current venue",selection:Binding(get:{ model.currentVenue },set:{ model.chooseVenue($0) })) { + Text("Select venue…").tag(nil as Int64?) + ForEach(model.venues) { Text($0.name).tag(Optional($0.id)) } + } + Button { model.creating = .venue } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add venue") + Picker("Singer",selection:$model.selectedSinger) { + Text("None").tag(nil as Int64?) + ForEach(model.venueSingers) { Text($0.name).tag(Optional($0.id)) } + } + Button { model.beginSinger() } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add singer") + Menu("Add to Singer") { + ForEach(model.venueSingers) { singer in Button(singer.name) { if let selected { model.assign(selected,to:singer.id);model.selectedSinger = singer.id;expandedSingers.insert(singer.id) } } } + Divider();Button("New Singer + Song…") { model.beginSinger(song:selected) } + }.disabled(selected == nil) + Button("Queue") { if let selected { model.enqueue(selected) } }.disabled(selected == nil || model.currentVenue == nil) + } + PlaybackSettings(player:model.player,model:model,selectedSong:selected) + SeekBar(player:model.player) + }.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity) + .background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + @State private var playlistName = "" + @State private var selectedPlaylist: Int64? + private var queuePane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { Text("Queue · \(model.currentVenueName)").font(.headline); Spacer(); Toggle("Auto play next",isOn:$model.autoAdvance).toggleStyle(.checkbox); Button("Play Next") { model.playQueueNext() }.disabled(model.queue.isEmpty) } + ScrollView { + VStack(spacing:0) { + ForEach(Array(model.queue.enumerated()),id:\.element.id) { index,row in + HStack { + Text("\(index+1)").foregroundStyle(.secondary).frame(width:24) + Text(row.singer).frame(width:140,alignment:.leading) + Text(row.title).lineLimit(1); Spacer() + Button { model.moveQueue(row.id,by:-1) } label: { Image(systemName:"arrow.up") }.disabled(index == 0) + Button { model.moveQueue(row.id,by:1) } label: { Image(systemName:"arrow.down") }.disabled(index == model.queue.count-1) + Button("Remove") { model.removeQueue(row.id) } + }.padding(.vertical,8) + } + if model.queue.isEmpty { Text("Choose a venue and singer, select a library song, then click Queue Song.").foregroundStyle(.secondary).padding(.top,30) } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + @State private var historyAllVenues = false + private var displayedHistory: [Performance] { + if historyAllVenues { return (try? model.library?.history(venue:nil,singer:model.selectedSinger)) ?? [] } + return model.history.filter { model.selectedSinger == nil || $0.singer == model.singers.first(where: { $0.id == model.selectedSinger })?.name } + } + private var historyPane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { Text("History · \(historyAllVenues || model.currentVenue == nil ? "All venues" : model.currentVenueName)").font(.headline); Spacer(); Toggle("All venues",isOn:$historyAllVenues).toggleStyle(.checkbox) } + ScrollView { + LazyVStack(alignment:.leading,spacing:12) { + ForEach(displayedHistory) { row in + HStack { Text(row.date).font(.system(.caption,design:.monospaced)); Text(row.singer).frame(width:130,alignment:.leading); Text(row.song); Spacer(); Text(row.venue).foregroundStyle(.secondary) } + } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + private var playlistPane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { + Picker("Playlist",selection:$selectedPlaylist) { Text("Choose…").tag(nil as Int64?); ForEach(model.playlists) { Text($0.name).tag(Optional($0.id)) } } + TextField("New playlist name",text:$playlistName).textFieldStyle(.roundedBorder) + Button("Create") { model.createPlaylist(playlistName); selectedPlaylist = model.playlists.first { $0.name == playlistName.trimmingCharacters(in:.whitespacesAndNewlines) }?.id; playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + if let selectedPlaylist { + Button("Rename") { model.rename("playlists",id:selectedPlaylist,name:playlistName); playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + Button("Delete",role:.destructive) { model.delete("playlists",id:selectedPlaylist); self.selectedPlaylist = nil } } + } + ScrollView { + if let selectedPlaylist { + let ids = (try? model.library?.playlistSongIDs(selectedPlaylist)) ?? [] + VStack(alignment:.leading,spacing:10) { + ForEach(ids,id:\.self) { id in + if let song = model.songs.first(where: { $0.id == id }) { + HStack { + Text(song.title); Spacer() + Button("Play") { model.perform { try model.play(song) } } + Button("Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Button("Remove") { model.perform { try model.library?.removeFromPlaylist(selectedPlaylist,song:id); try model.refresh() } } + } + } + } + } + } + Text("Add songs using the library row’s context menu.").foregroundStyle(.secondary).padding(.top,20) + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + private var footer: some View { + HStack { + Button { importing = true } label: { Image(systemName: "plus").font(.system(size: 20, weight: .bold)).frame(width: 32, height: 24) } + .help("Import audio, MP3+G or ZIP").accessibilityLabel("Import songs") + RepeatButton(player: model.player).padding(.leading, 12) + Spacer() + Text("\(visibleSongs.count) \(visibleSongs.count == 1 ? "Song" : "Songs")").font(.system(size: 14, weight: .medium)) + Spacer() + Button { openWindow(id: "lyrics") } label: { Image(systemName: "rectangle.on.rectangle").frame(width: 32, height: 24) } + .help("Open video window").accessibilityLabel("Open video window") + }.buttonStyle(SilverSquareStyle()) + } + private func playSelected() { + guard let song = selectedSong else { return } + model.perform { try model.play(song) } + } + private func playNext() { + guard !visibleSongs.isEmpty else { return } + let index = visibleSongs.firstIndex { $0.id == selected } ?? -1 + selected = visibleSongs[(index + 1) % visibleSongs.count].id; playSelected() + } + private func addEntity() { + model.perform { + let kind: EntityKind = source == "Singers" ? .singer : .venue + let id = try model.createEntity(kind, name: name) + if kind == .singer { model.selectedSinger = id } else { model.chooseVenue(id) } + name = "" + } + } +} +private struct SingerSongsSheet: View { + @ObservedObject var model: AppModel + let singer: PersonPlace + @Environment(\.dismiss) private var dismiss + @State private var search = "" + @State private var importing = false + var body: some View { + VStack(alignment:.leading,spacing:14) { + Text("Songs for \(singer.name)").font(.title2.bold()) + Text("Tick songs to add them to this singer. Unticking removes only the assignment.").font(.callout).foregroundStyle(.secondary) + TextField("Search library",text:$search).textFieldStyle(.roundedBorder) + ScrollView { + LazyVStack(alignment:.leading,spacing:12) { + ForEach(model.songs.filter { search.isEmpty || $0.title.localizedCaseInsensitiveContains(search) }) { song in + Toggle(song.title,isOn:Binding(get:{ model.singerSongIDs[singer.id]?.contains(song.id) == true },set:{ model.assign(song.id,to:singer.id,included:$0) })).toggleStyle(.checkbox) + } + if model.songs.isEmpty { Text("Your library is empty. Import audio or MP3+G below.").foregroundStyle(.secondary) } + }.frame(maxWidth:.infinity,alignment:.leading) + }.frame(height:270) + HStack { Button("Import Songs…") { importing = true };Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) } + }.padding(24).frame(width:520) + .fileImporter(isPresented:$importing,allowedContentTypes:[.audio,.zip,UTType(filenameExtension:"cdg") ?? .data],allowsMultipleSelection:true) { result in + switch result { case .success(let urls):model.importFiles(urls);case .failure(let error):model.error = error.localizedDescription } + } + } +} +private struct AddEntitySheet: View { + @ObservedObject var model: AppModel + let kind: EntityKind + let saved: (Int64) -> Void + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var error: String? + @State private var venue: Int64? + @State private var songs = Set() + @State private var search = "" + @FocusState private var focused: Bool + var body: some View { + VStack(alignment:.leading,spacing:16) { + Text(kind == .singer ? "New Singer + Songs" : "New Venue").font(.title2.bold()) + TextField("\(kind.title) name",text:$name).textFieldStyle(.roundedBorder).focused($focused).onSubmit(save) + if kind == .singer { + Picker("Venue",selection:$venue) { Text("Choose a venue…").tag(nil as Int64?);ForEach(model.venues) { Text($0.name).tag(Optional($0.id)) } } + Text(venue == nil ? "Create a venue first, then add a singer to it." : "This singer will join the selected venue and become active.").font(.caption).foregroundStyle(.secondary) + Text("Add songs now (optional)").font(.headline) + TextField("Search library",text:$search).textFieldStyle(.roundedBorder) + ScrollView { + LazyVStack(alignment:.leading,spacing:10) { + ForEach(model.songs.filter { search.isEmpty || $0.title.localizedCaseInsensitiveContains(search) }) { song in + Toggle(song.title,isOn:Binding(get:{ songs.contains(song.id) },set:{ if $0 { songs.insert(song.id) } else { songs.remove(song.id) } })).toggleStyle(.checkbox) + } + if model.songs.isEmpty { Text("No library songs yet. You can add songs from the singer’s + button after importing.").font(.callout).foregroundStyle(.secondary) } + }.frame(maxWidth:.infinity,alignment:.leading) + }.frame(height:190) + } + if let error { Text(error).foregroundStyle(.red).font(.callout) } + HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Button(kind == .singer ? "Add Singer & Songs" : "Add Venue",action:save).keyboardShortcut(.defaultAction) + .disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty || (kind == .singer && venue == nil)) + } + }.padding(24).frame(width:kind == .singer ? 500 : 360) + .onAppear { focused = true;venue = model.currentVenue ?? model.venues.first?.id;songs = model.newSingerSongs } + } + private func save() { + do { + let id: Int64 + if kind == .singer { guard let venue else { throw LibraryError.message("Choose a venue") };id = try model.createSinger(name:name,venue:venue,songs:songs) } + else { id = try model.createEntity(kind,name:name) } + saved(id);dismiss() + } catch { self.error = error.localizedDescription } + } +} +private struct TransportView: View { + @ObservedObject var player: Player + @Binding var search: String + let hasSelection: Bool + let play: () -> Void + let next: () -> Void + let display: () -> Void + var body: some View { + HStack(alignment: .top, spacing: 28) { + VStack(spacing: 15) { + HStack(spacing: 7) { + SilverButton(symbol: "backward.fill", label: "Back 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time - 10) } + SilverButton(symbol: player.playing ? "pause.fill" : "play.fill", label: player.playing ? "Pause" : "Play", size: 49, enabled: hasSelection || player.duration > 0) { + if player.duration > 0 { player.toggle() } else { play() } + } + SilverButton(symbol: "stop", label: "Stop", size: 46, enabled: player.duration > 0) { player.stop() } + SilverButton(symbol: "forward.fill", label: "Forward 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time + 10) } + SilverButton(symbol: "forward.end.fill", label: "Play next song", size: 26, enabled: hasSelection, action: next) + } + HStack(spacing: 9) { + Image(systemName: "speaker.fill").font(.system(size: 11)) + Slider(value: $player.volume, in: 0...1).controlSize(.small).tint(.gray).accessibilityLabel("Volume") + Image(systemName: "speaker.wave.3.fill").font(.system(size: 12)) + }.frame(width: 180) + }.frame(width: 220) + NowPlayingWaveform(player: player).padding(.top, 4) + HStack(spacing: 12) { + TextField("Search", text: $search).textFieldStyle(.plain).font(.system(size: 16)) + .padding(.horizontal, 8).frame(width: 145, height: 28).background(Color(white: 0.07), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).stroke(.white.opacity(0.1))) + SilverButton(symbol: "eye", label: "Open video window", size: 48, action: display) + }.padding(.top, 15) + }.foregroundStyle(.white).frame(height: 114) + } +} +private struct NowPlayingWaveform: View { + @ObservedObject var player: Player + @State private var mixer = false + private var progress: Double { player.duration > 0 ? min(1,max(0,player.time/player.duration)) : 0 } + private func clock(_ value: Double) -> String { let n = max(0,Int(value));return String(format:"%d:%02d",n/60,n%60) } + private func bandColor(_ index: Int) -> Color { + guard player.waveformBands.indices.contains(index) else { return .cyan } + let band = player.waveformBands[index] + let largest = max(0.000001,max(band.low,max(band.mid,band.high))) + let low = Double(band.low/largest),mid = Double(band.mid/largest),high = Double(band.high/largest) + return Color(red:min(1,low+mid*0.18+high*0.65),green:min(1,low*0.18+mid+high*0.2),blue:min(1,low*0.22+mid*0.28+high)) + } + var body: some View { + VStack(alignment:.leading,spacing:5) { + Text(player.duration > 0 ? player.title : "Ready to play").font(.system(size:13,weight:.semibold)).foregroundStyle(Color(white:0.94)) + .lineLimit(1).truncationMode(.middle).frame(maxWidth:.infinity,alignment:.leading).help(player.title) + GeometryReader { geometry in + Canvas { context,size in + let top: CGFloat = 10,plotHeight = size.height-top,mid = top+plotHeight/2 + var centre = Path();centre.move(to:CGPoint(x:0,y:mid));centre.addLine(to:CGPoint(x:size.width,y:mid)) + context.stroke(centre,with:.color(.white.opacity(0.13)),lineWidth:0.5) + if player.duration > 0 { + let step = [1.0,2,5,10,15,30,60,120,300,600,1800,3600].first { $0 >= player.duration/5 } ?? 3600 + for t in stride(from:0.0,through:player.duration,by:step/4) { + let x = size.width*t/player.duration + let major = abs(t/step-(t/step).rounded()) < 0.001 + var tick = Path();tick.move(to:CGPoint(x:x,y:top));tick.addLine(to:CGPoint(x:x,y:size.height)) + context.stroke(tick,with:.color(major ? .yellow.opacity(0.65) : .white.opacity(0.16)),lineWidth:major ? 0.8 : 0.5) + if major && x < size.width-25 { + context.draw(Text(clock(t)).font(.system(size:8,design:.monospaced)).foregroundColor(.gray),at:CGPoint(x:x+3,y:3),anchor:.leading) + } + } + } + if !player.waveform.isEmpty { + let count = max(1,Int(size.width/2)),step = size.width/CGFloat(count) + for bar in 0.. 0,geometry.size.width > 0 else { return } + player.seek(min(1,max(0,value.location.x/geometry.size.width))*player.duration) + }).help("Frequency colours: bass red, mids green, treble blue. Click to seek.") + .accessibilityLabel("Audio waveform").accessibilityValue("\(clock(player.time)) of \(clock(player.duration))") + .accessibilityAdjustableAction { direction in if direction == .increment { player.seek(player.time+5) } else if direction == .decrement { player.seek(player.time-5) } } + }.frame(height:44) + HStack(spacing:5) { + Circle().fill(player.playing ? Color.green : Color.orange).frame(width:4,height:4) + Text(player.duration == 0 ? "NO TRACK LOADED" : player.playing ? "PLAYING" : "PAUSED").tracking(1) + if player.waveformUnavailable { Text("· Waveform unavailable").foregroundStyle(.secondary) } + Spacer(minLength:0) + Button("EQ / TONE") { mixer.toggle() }.buttonStyle(.plain).foregroundStyle(.white.opacity(0.85)) + .padding(.horizontal,6).padding(.vertical,2).background(.white.opacity(0.08),in:RoundedRectangle(cornerRadius:3)) + .popover(isPresented:$mixer) { ToneMixerView(player:player) } + Text("\(clock(player.time)) / \(clock(player.duration))").monospacedDigit().padding(.leading,5) + }.font(.system(size:9,weight:.medium)).foregroundStyle(Color(white:0.6)) + }.padding(.horizontal,12).padding(.vertical,9).frame(maxWidth:.infinity).frame(height:104) + .background(Color(red:0.025,green:0.028,blue:0.035)).overlay(Rectangle().strokeBorder(Color(white:0.24),lineWidth:1)) + } +} +private struct ToneKnob: View { + let title: String + @Binding var value: Double + let range: ClosedRange + var filter = false + @State private var initial: Double? + private var fraction: Double { min(1,max(0,(value-range.lowerBound)/(range.upperBound-range.lowerBound))) } + private var display: String { + if filter { return abs(value) < 0.01 ? "OFF" : value < 0 ? "LOW PASS" : "HIGH PASS" } + return String(format:"%+.1f dB",value) + } + var body: some View { + VStack(spacing:4) { + ZStack { + Circle().trim(from:0,to:0.75).stroke(Color(white:0.21),style:StrokeStyle(lineWidth:4,lineCap:.round)).rotationEffect(.degrees(135)) + Circle().trim(from:0,to:0.75*fraction).stroke(Color(white:0.48),style:StrokeStyle(lineWidth:3,lineCap:.round)).rotationEffect(.degrees(135)) + Circle().fill(LinearGradient(colors:[Color(white:0.19),Color(white:0.09)],startPoint:.top,endPoint:.bottom)).padding(6).overlay(Circle().stroke(.black.opacity(0.65),lineWidth:1.5).padding(6)) + Capsule().fill(.white).frame(width:2,height:12).offset(y:-13).rotationEffect(.degrees(-135+270*fraction)) + }.frame(width:50,height:50).contentShape(Circle()) + .gesture(DragGesture(minimumDistance:0).onChanged { gesture in + if initial == nil { initial = value } + value = min(range.upperBound,max(range.lowerBound,(initial ?? value)-Double(gesture.translation.height)/130*(range.upperBound-range.lowerBound))) + }.onEnded { _ in initial = nil }) + .simultaneousGesture(TapGesture(count:2).onEnded { value = 0 }) + .accessibilityElement().accessibilityLabel(title).accessibilityValue(display) + .accessibilityAdjustableAction { direction in + let change = (range.upperBound-range.lowerBound)/24 + value = min(range.upperBound,max(range.lowerBound,value+(direction == .increment ? change : -change))) + }.help("Drag up/down to adjust. Double-click to reset.") + Text(title).font(.system(size:10,weight:.bold)).foregroundStyle(Color(white:0.6)) + Text(display).font(.system(size:8,design:.monospaced)).foregroundStyle(Color(white:0.45)) + } + } +} +private struct StereoMeter: View { + @ObservedObject var player: Player + private func normal(_ level: Float) -> Double { max(0,min(1,(20*log10(Double(max(level,0.000001)))+60)/60)) } + var body: some View { + HStack(spacing:3) { + ForEach(0..<2) { channel in + VStack(spacing:2) { + ForEach((0..<24).reversed(),id:\.self) { segment in + let on = Double(segment)/24 < normal(channel == 0 ? player.leftLevel : player.rightLevel) + Rectangle().fill(on ? (segment > 21 ? Color.red : segment > 17 ? Color.yellow : Color.green) : Color.black.opacity(0.5)) + } + } + } + }.frame(width:23,height:125).accessibilityLabel("Stereo output levels") + } +} +private struct MixerFader: View { + @Binding var value: Double + var body: some View { + GeometryReader { geometry in + let travel = geometry.size.height-22 + ZStack(alignment:.top) { + VStack { ForEach(0..<7) { _ in Rectangle().fill(.white.opacity(0.09)).frame(height:1);Spacer(minLength:0) } }.padding(.vertical,11) + Capsule().fill(.black.opacity(0.8)).frame(width:5).padding(.vertical,8) + RoundedRectangle(cornerRadius:5).fill(LinearGradient(colors:[Color(white:0.45),Color(white:0.23)],startPoint:.top,endPoint:.bottom)) + .frame(width:45,height:20).overlay(Rectangle().fill(.white.opacity(0.15)).frame(width:18,height:1)) + .offset(y:(1-value)*travel) + }.frame(maxWidth:.infinity).contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance:0).onChanged { value = min(1,max(0,1-Double(($0.location.y-11)/travel))) }) + }.frame(width:56,height:135).accessibilityLabel("Master volume").accessibilityValue("\(Int(value*100)) percent") + .accessibilityAdjustableAction { direction in value = min(1,max(0,value+(direction == .increment ? 0.05 : -0.05))) } + } +} +private struct ToneMixerView: View { + @ObservedObject var player: Player + var body: some View { + VStack(spacing:14) { + HStack { Text("TONE / MIXER").font(.system(size:12,weight:.bold));Spacer();Toggle("EQ",isOn:$player.toneEnabled).toggleStyle(.switch).controlSize(.mini) } + HStack(alignment:.top,spacing:28) { + VStack(spacing:8) { + ToneKnob(title:"HIGH",value:$player.highGain,range:-12...12) + ToneKnob(title:"MID",value:$player.midGain,range:-12...12) + ToneKnob(title:"LOW",value:$player.lowGain,range:-12...12) + } + VStack(spacing:15) { + ToneKnob(title:"FILTER",value:$player.filter,range:-1...1,filter:true) + HStack(spacing:20) { StereoMeter(player:player);MixerFader(value:$player.volume) } + Text(player.volume <= 0 ? "−∞ dB" : String(format:"%+.1f dB",20*log10(player.volume))).font(.system(size:10,design:.monospaced)).foregroundStyle(.secondary) + } + }.padding(.horizontal,15) + HStack { Text("Drag knobs · Double-click to reset").font(.system(size:9)).foregroundStyle(.secondary);Spacer();Button("Reset EQ") { player.resetTone() }.controlSize(.small) } + }.padding(18).frame(width:270).background(Color(white:0.105)).preferredColorScheme(.dark) + } +} +private struct PlaybackSettings: View { + @ObservedObject var player: Player + @ObservedObject var model: AppModel + let selectedSong: Int64? + @State private var personalRating = 0 + var body: some View { + HStack(spacing:10) { + Text("Key") + Picker("Key",selection:$player.pitch) { ForEach(-12..<13) { Text(String(format:"%+d",$0)).tag(Double($0)) } }.labelsHidden().frame(width:70) + Text("Tempo") + Slider(value:$player.tempo,in:0.5...1.5,step:0.05).frame(width:85) + Text(String(format:"%.0f%%",player.tempo*100)).monospacedDigit().frame(width:40) + Text("Rating") + Picker("Singer rating",selection:$personalRating) { ForEach(0..<6) { Text($0 == 0 ? "Unrated" : String(repeating:"★",count:$0)).tag($0) } }.labelsHidden().frame(width:88).help("Singer-specific song rating") + Button("Save for Singer") { + if let song = player.currentSongID ?? selectedSong { model.savePreference(song:song,value:.init(pitch:player.pitch,tempo:player.tempo,rating:personalRating)) } + }.disabled(model.selectedSinger == nil || (player.currentSongID == nil && selectedSong == nil)) + Menu { + Button("Reset key / tempo") { player.pitch = 0; player.tempo = 1 } + ForEach([-1.0,-0.5,0,0.5,1.0],id:\.self) { offset in Button(String(format:"Graphics offset %+.1fs",offset)) { player.graphicsOffset = offset } } + } label: { Image(systemName:"slider.horizontal.3") }.menuStyle(.borderlessButton).frame(width:22).help("Playback settings") + }.font(.system(size:11)) + .onChange(of:model.selectedSinger) { _ in loadRating() } + .onChange(of:player.currentSongID) { _ in loadRating() } + .onChange(of:selectedSong) { _ in loadRating() } + } + private func loadRating() { if let song = player.currentSongID ?? selectedSong { personalRating = model.preference(song).rating } else { personalRating = 0 } } +} +private struct SeekBar: View { + @ObservedObject var player: Player + private func clock(_ seconds: Double) -> String { let n = Int(seconds); return String(format: "%d:%02d", n / 60, n % 60) } + var body: some View { + HStack(spacing: 10) { + Text(clock(player.time)) + Slider(value: Binding(get: { player.time }, set: { player.seek($0) }), in: 0...max(1, player.duration)) + .controlSize(.mini).tint(.gray).disabled(player.duration == 0).accessibilityLabel("Playback position") + Text(clock(player.duration)) + }.font(.system(size: 10, design: .monospaced)).foregroundStyle(.white.opacity(0.65)) + } +} +private struct RepeatButton: View { + @ObservedObject var player: Player + var body: some View { + Button { player.repeating.toggle() } label: { + Image(systemName: "repeat").font(.system(size: 18, weight: .semibold)) + .foregroundStyle(player.repeating ? Color.blue : Color(white: 0.25)).frame(width: 32, height: 24) + }.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off") + } +} diff --git a/backups/before-classic-library-20261103-085620/KJamsClone/Library.swift b/backups/before-classic-library-20261103-085620/KJamsClone/Library.swift new file mode 100644 index 0000000..de9da0c --- /dev/null +++ b/backups/before-classic-library-20261103-085620/KJamsClone/Library.swift @@ -0,0 +1,219 @@ +import Foundation +import SQLite3 + +struct Song: Identifiable, Hashable { + var id: Int64 + var title: String + var audio: String + var cdg: String? + var rating: Int + var plays: Int + var artist: String = "" + var album: String = "" + var label: String = "" +} +struct PersonPlace: Identifiable, Hashable { var id: Int64; var name: String } +struct SongPreference: Equatable { + var pitch: Double = 0 + var tempo: Double = 1 + var rating: Int = 0 +} +struct Performance: Identifiable { + var id: Int64 + var song: String + var singer: String + var venue: String + var date: String +} +struct QueueEntry: Identifiable { + var id: Int64 + var songID: Int64 + var singerID: Int64? + var title: String + var singer: String +} +enum LibraryError: LocalizedError { + case message(String) + var errorDescription: String? { if case .message(let s) = self { return s }; return nil } +} +final class Library { + private var db: OpaquePointer? + init(url: URL) throws { + guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw LibraryError.message("Cannot open library") } + try execute("PRAGMA foreign_keys=ON") + try execute("CREATE TABLE IF NOT EXISTS songs(id INTEGER PRIMARY KEY,title TEXT NOT NULL,audio TEXT UNIQUE NOT NULL,cdg TEXT,rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),plays INTEGER NOT NULL DEFAULT 0)") + try execute("CREATE TABLE IF NOT EXISTS singers(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS venues(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS history(id INTEGER PRIMARY KEY,song INTEGER REFERENCES songs(id),singer INTEGER REFERENCES singers(id),venue INTEGER REFERENCES venues(id),played TEXT DEFAULT CURRENT_TIMESTAMP)") + // Additive migration: preserve older libraries and infer existing memberships from history. + for column in ["artist", "album", "label"] { + if try !columns("songs").contains(column) { try execute("ALTER TABLE songs ADD COLUMN \(column) TEXT NOT NULL DEFAULT ''") } + } + try execute("CREATE TABLE IF NOT EXISTS venue_singers(venue INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,PRIMARY KEY(venue,singer))") + if try schemaVersion() < 3 { try execute("INSERT OR IGNORE INTO venue_singers SELECT DISTINCT venue,singer FROM history WHERE venue IS NOT NULL AND singer IS NOT NULL") } + try execute("CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT)") + try execute("CREATE TABLE IF NOT EXISTS preferences(song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5),rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),PRIMARY KEY(song,singer))") + try execute("CREATE TABLE IF NOT EXISTS queue(id INTEGER PRIMARY KEY,venue INTEGER REFERENCES venues(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER REFERENCES singers(id) ON DELETE CASCADE,position INTEGER NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS playlists(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS playlist_songs(playlist INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(playlist,song))") + try execute("CREATE TABLE IF NOT EXISTS singer_songs(singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(singer,song))") + if try schemaVersion() < 4 { + try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,MIN(id) FROM history WHERE singer IS NOT NULL AND song IS NOT NULL GROUP BY singer,song") + try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,0 FROM preferences") + } + try execute("PRAGMA user_version=4") + } + deinit { sqlite3_close(db) } + private func statement(_ sql: String, _ values: [String?]) throws -> OpaquePointer { + var s: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &s, nil) == SQLITE_OK, let s else { throw error() } + for (i,v) in values.enumerated() { + let code = v.map { sqlite3_bind_text(s, Int32(i + 1), $0, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) } ?? sqlite3_bind_null(s, Int32(i + 1)) + if code != SQLITE_OK { sqlite3_finalize(s); throw error() } + } + return s + } + private func error() -> LibraryError { .message(String(cString: sqlite3_errmsg(db))) } + func execute(_ sql: String, _ values: [String?] = []) throws { + let s = try statement(sql, values); defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_DONE else { throw error() } + } + func songs() throws -> [Song] { + let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label FROM songs ORDER BY title COLLATE NOCASE", []); defer { sqlite3_finalize(s) } + var result = [Song]() + while true { + let code = sqlite3_step(s); if code == SQLITE_DONE { break }; guard code == SQLITE_ROW else { throw error() } + result.append(Song(id: sqlite3_column_int64(s,0), title: String(cString: sqlite3_column_text(s,1)), audio: String(cString: sqlite3_column_text(s,2)), cdg: sqlite3_column_text(s,3).map { String(cString:$0) }, rating: Int(sqlite3_column_int(s,4)), plays: Int(sqlite3_column_int(s,5)), artist: text(s,6), album: text(s,7), label: text(s,8))) + } + return result + } + func add(title: String, audio: String, cdg: String?, artist: String = "", album: String = "", label: String = "") throws { + try execute("INSERT INTO songs(title,audio,cdg,artist,album,label) VALUES(?,?,?,?,?,?) ON CONFLICT(audio) DO UPDATE SET cdg=COALESCE(excluded.cdg,songs.cdg),artist=CASE WHEN excluded.artist!='' THEN excluded.artist ELSE songs.artist END,album=CASE WHEN excluded.album!='' THEN excluded.album ELSE songs.album END,label=CASE WHEN excluded.label!='' THEN excluded.label ELSE songs.label END", [title,audio,cdg,artist,album,label]) + } + func rate(_ id: Int64, _ rating: Int) throws { try execute("UPDATE songs SET rating=? WHERE id=?", [String(rating),String(id)]) } + func entities(_ table: String) throws -> [PersonPlace] { + precondition(["singers","venues","playlists"].contains(table)) + let s = try statement("SELECT id,name FROM \(table) ORDER BY name", []); defer { sqlite3_finalize(s) } + var rows = [PersonPlace]() + while sqlite3_step(s) == SQLITE_ROW { rows.append(.init(id: sqlite3_column_int64(s,0), name: String(cString: sqlite3_column_text(s,1)))) } + return rows + } + func addEntity(_ table: String, name: String) throws { + precondition(["singers","venues","playlists"].contains(table)) + let name = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { throw LibraryError.message("Enter a name") } + try execute("INSERT OR IGNORE INTO \(table)(name) VALUES(?)", [name]) + } + func record(_ song: Int64, singer: Int64?, venue: Int64?) throws { + try execute("BEGIN IMMEDIATE") + do { + if let singer { try assignSong(song,singer:singer) } + try execute("INSERT INTO history(song,singer,venue) VALUES(?,?,?)", [String(song),singer.map(String.init),venue.map(String.init)]) + try execute("UPDATE songs SET plays=plays+1 WHERE id=?", [String(song)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK"); throw error } + } + + private func text(_ s: OpaquePointer, _ index: Int32) -> String { sqlite3_column_text(s,index).map { String(cString:$0) } ?? "" } + private func schemaVersion() throws -> Int { + let s = try statement("PRAGMA user_version",[]); defer { sqlite3_finalize(s) } + return sqlite3_step(s) == SQLITE_ROW ? Int(sqlite3_column_int(s,0)) : 0 + } + private func columns(_ table: String) throws -> Set { + let s = try statement("PRAGMA table_info(\(table))", []); defer { sqlite3_finalize(s) } + var result = Set(); while sqlite3_step(s) == SQLITE_ROW { result.insert(text(s,1)) }; return result + } + func members(of venue: Int64) throws -> [PersonPlace] { + let s = try statement("SELECT singers.id,singers.name FROM singers JOIN venue_singers ON singers.id=venue_singers.singer WHERE venue=? ORDER BY name", [String(venue)]); defer { sqlite3_finalize(s) } + var result = [PersonPlace](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1))) }; return result + } + func setMembership(singer: Int64, venue: Int64, included: Bool) throws { + try execute(included ? "INSERT OR IGNORE INTO venue_singers(venue,singer) VALUES(?,?)" : "DELETE FROM venue_singers WHERE venue=? AND singer=?", [String(venue),String(singer)]) + if !included { try execute("DELETE FROM queue WHERE venue=? AND singer=?",[String(venue),String(singer)]) } + } + func currentVenue() throws -> Int64? { + let s = try statement("SELECT venues.id FROM venues JOIN settings ON CAST(settings.value AS INTEGER)=venues.id WHERE settings.key='currentVenue'", []); defer { sqlite3_finalize(s) } + return sqlite3_step(s) == SQLITE_ROW ? sqlite3_column_int64(s,0) : nil + } + func selectVenue(_ id: Int64?) throws { + if let id { + guard try entities("venues").contains(where: { $0.id == id }) else { throw LibraryError.message("Venue no longer exists") } + } + try execute("INSERT INTO settings(key,value) VALUES('currentVenue',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",[id.map(String.init)]) + } + func rename(_ table: String, id: Int64, name: String) throws { + precondition(["singers","venues","playlists"].contains(table)) + let name = name.trimmingCharacters(in:.whitespacesAndNewlines) + guard !name.isEmpty else { throw LibraryError.message("Enter a name") } + try execute("UPDATE \(table) SET name=? WHERE id=?",[name,String(id)]) + } + func deleteEntity(_ table: String, id: Int64) throws { + precondition(["singers","venues","playlists"].contains(table)) + try execute("BEGIN IMMEDIATE") + do { + if table == "singers" { try execute("UPDATE history SET singer=NULL WHERE singer=?",[String(id)]) } + if table == "venues" { + try execute("UPDATE history SET venue=NULL WHERE venue=?",[String(id)]) + try execute("DELETE FROM settings WHERE key='currentVenue' AND value=?",[String(id)]) + } + try execute("DELETE FROM \(table) WHERE id=?",[String(id)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK"); throw error } + } + func preference(song: Int64, singer: Int64) throws -> SongPreference { + let s = try statement("SELECT pitch,tempo,rating FROM preferences WHERE song=? AND singer=?",[String(song),String(singer)]); defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_ROW else { return .init() } + return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:Int(sqlite3_column_int(s,2))) + } + func savePreference(song: Int64, singer: Int64, value: SongPreference) throws { + try execute("INSERT INTO preferences(song,singer,pitch,tempo,rating) VALUES(?,?,?,?,?) ON CONFLICT(song,singer) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo,rating=excluded.rating",[String(song),String(singer),String(value.pitch),String(value.tempo),String(value.rating)]) + } + func history(venue: Int64?, singer: Int64? = nil) throws -> [Performance] { + let s = try statement("SELECT history.id,COALESCE(songs.title,'Deleted song'),COALESCE(singers.name,'—'),COALESCE(venues.name,'—'),played FROM history LEFT JOIN songs ON songs.id=history.song LEFT JOIN singers ON singers.id=history.singer LEFT JOIN venues ON venues.id=history.venue WHERE (? IS NULL OR history.venue=?) AND (? IS NULL OR history.singer=?) ORDER BY history.id DESC",[venue.map(String.init),venue.map(String.init),singer.map(String.init),singer.map(String.init)]); defer { sqlite3_finalize(s) } + var result = [Performance](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),song:text(s,1),singer:text(s,2),venue:text(s,3),date:text(s,4))) }; return result + } + func enqueue(song: Int64, singer: Int64?, venue: Int64?) throws { + guard let venue else { throw LibraryError.message("Select a current venue before adding to its queue") } + if let singer, try !members(of:venue).contains(where: { $0.id == singer }) { throw LibraryError.message("Add this singer to the current venue first") } + try execute("BEGIN IMMEDIATE") + do { + if let singer { try assignSong(song,singer:singer) } + try execute("INSERT INTO queue(venue,song,singer,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?))",[String(venue),String(song),singer.map(String.init),String(venue)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK");throw error } + } + func queue(venue: Int64?) throws -> [QueueEntry] { + guard let venue else { return [] } + let s = try statement("SELECT queue.id,queue.song,queue.singer,songs.title,COALESCE(singers.name,'No singer') FROM queue JOIN songs ON songs.id=queue.song LEFT JOIN singers ON singers.id=queue.singer WHERE queue.venue=? ORDER BY queue.position,queue.id",[String(venue)]); defer { sqlite3_finalize(s) } + var result = [QueueEntry](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),songID:sqlite3_column_int64(s,1),singerID:sqlite3_column_type(s,2) == SQLITE_NULL ? nil : sqlite3_column_int64(s,2),title:text(s,3),singer:text(s,4))) }; return result + } + func removeQueue(_ id: Int64) throws { try execute("DELETE FROM queue WHERE id=?",[String(id)]) } + func moveQueue(_ id: Int64, venue: Int64, by offset: Int) throws { + var rows = try queue(venue:venue) + guard let index = rows.firstIndex(where: { $0.id == id }), rows.indices.contains(index+offset) else { return } + rows.swapAt(index,index+offset) + try execute("BEGIN IMMEDIATE") + do { for (i,row) in rows.enumerated() { try execute("UPDATE queue SET position=? WHERE id=?",[String(i),String(row.id)]) }; try execute("COMMIT") } + catch { try? execute("ROLLBACK"); throw error } + } + func addToPlaylist(_ playlist: Int64, song: Int64) throws { + try execute("INSERT OR IGNORE INTO playlist_songs(playlist,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM playlist_songs WHERE playlist=?))",[String(playlist),String(song),String(playlist)]) + } + func playlistSongIDs(_ playlist: Int64) throws -> [Int64] { + let s = try statement("SELECT song FROM playlist_songs WHERE playlist=? ORDER BY position",[String(playlist)]); defer { sqlite3_finalize(s) } + var ids = [Int64](); while sqlite3_step(s) == SQLITE_ROW { ids.append(sqlite3_column_int64(s,0)) }; return ids + } + func removeFromPlaylist(_ playlist: Int64, song: Int64) throws { try execute("DELETE FROM playlist_songs WHERE playlist=? AND song=?",[String(playlist),String(song)]) } + func assignSong(_ song: Int64, singer: Int64) throws { + try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_songs WHERE singer=?))",[String(singer),String(song),String(singer)]) + } + func removeSingerSong(_ song: Int64, singer: Int64) throws { + try execute("DELETE FROM singer_songs WHERE singer=? AND song=?",[String(singer),String(song)]) + } + func singerSongIDs() throws -> [Int64:[Int64]] { + let s = try statement("SELECT singer,song FROM singer_songs ORDER BY singer,position,song",[]); defer { sqlite3_finalize(s) } + var result = [Int64:[Int64]]() + while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].append(sqlite3_column_int64(s,1)) } + return result + } +} diff --git a/backups/before-classic-library-20261103-085620/KJamsClone/Player.swift b/backups/before-classic-library-20261103-085620/KJamsClone/Player.swift new file mode 100644 index 0000000..1cff467 --- /dev/null +++ b/backups/before-classic-library-20261103-085620/KJamsClone/Player.swift @@ -0,0 +1,228 @@ +import SwiftUI +import AVFoundation + +/// CDG follows consumed source frames, so tempo adjustment preserves graphics timing. +@MainActor final class Player: ObservableObject { + @Published var image: CGImage? + @Published private(set) var waveform = [Float]() + @Published private(set) var waveformBands = [WaveformBands]() + @Published var lowGain: Double = 0 { didSet { updateTone() } } + @Published var midGain: Double = 0 { didSet { updateTone() } } + @Published var highGain: Double = 0 { didSet { updateTone() } } + @Published var filter: Double = 0 { didSet { updateTone() } } + @Published var toneEnabled = true { didSet { updateTone() } } + @Published private(set) var leftLevel: Float = 0 + @Published private(set) var rightLevel: Float = 0 + private let tone = AVAudioUnitEQ(numberOfBands:4) + private let meter = AudioLevelMeter() + private var metering = false + @Published private(set) var waveformLoading = false + @Published private(set) var waveformUnavailable = false + private var waveformTask: Task? + private var waveformGeneration = UUID() + @Published var playing = false + @Published var time: Double = 0 + @Published var duration: Double = 0 + @Published var title = "Select a song" + @Published var volume: Double = 0.8 { didSet { engine.mainMixerNode.outputVolume = Float(volume) } } + @Published var repeating = false + @Published var pitch: Double = 0 { didSet { effect.pitch = Float(pitch * 100) } } + @Published var tempo: Double = 1 { didSet { effect.rate = Float(tempo) } } + @Published var graphicsOffset: Double = 0 + private(set) var currentSongID: Int64? + var onFinished: (() -> Void)? + private let engine = AVAudioEngine() + private let node = AVAudioPlayerNode() + private let effect = AVAudioUnitTimePitch() + private var file: AVAudioFile? + private var anchor: Double = 0 + private var generation = UUID() + private var cdg = Data() + private var decoder = CDGDecoder() + private var timer: Timer? + init() { engine.attach(node); engine.attach(effect); engine.attach(tone); updateTone() } + func resetTone() { lowGain = 0; midGain = 0; highGain = 0; filter = 0; toneEnabled = true } + private func updateTone() { + AudioTone.configure(tone,sampleRate:file?.processingFormat.sampleRate ?? 44100,low:lowGain,mid:midGain,high:highGain,filter:filter,enabled:toneEnabled) + } + func load(_ song: Song, preference: SongPreference = .init()) throws { + stop() + do { + let loaded = try AVAudioFile(forReading:URL(fileURLWithPath:song.audio)) + let graphics = try song.cdg.map { try Data(contentsOf:URL(fileURLWithPath:$0)) } ?? Data() + guard loaded.length > 0 else { throw LibraryError.message("Audio file is empty") } + engine.connect(node,to:effect,format:loaded.processingFormat) + engine.connect(effect,to:tone,format:loaded.processingFormat) + engine.connect(tone,to:engine.mainMixerNode,format:loaded.processingFormat) + file = loaded; cdg = graphics; decoder = CDGDecoder() + duration = Double(loaded.length)/loaded.processingFormat.sampleRate + title = song.title; currentSongID = song.id + pitch = preference.pitch; tempo = preference.tempo + updateTone() + let meter = self.meter + engine.mainMixerNode.installTap(onBus:0,bufferSize:1024,format:nil) { buffer, _ in meter.consume(buffer) } + metering = true + engine.mainMixerNode.outputVolume = Float(volume) + engine.prepare(); try engine.start() + schedule(at:0,play:true); startTimer(); tick() + analyseWaveform(URL(fileURLWithPath:song.audio)) + } catch { stop(); throw error } + } + private func analyseWaveform(_ url: URL) { + let token = UUID(); waveformGeneration = token; waveformLoading = true + waveformTask = Task.detached(priority: .utility) { [weak self] in + let result: Result + do { result = .success(try AudioWaveform.analyse(url: url, cancelled: { Task.isCancelled })) } + catch { result = .failure(error) } + guard !Task.isCancelled else { return } + await MainActor.run { [weak self] in + guard let self, self.waveformGeneration == token else { return } + self.waveformLoading = false + switch result { + case .success(let analysis): self.waveform = analysis.levels; self.waveformBands = analysis.bands + case .failure: self.waveformUnavailable = true + } + } + } + } + private func schedule(at seconds: Double, play: Bool) { + guard let file else { return } + generation = UUID(); let token = generation + node.stop() + let rate = file.processingFormat.sampleRate + let start = min(file.length,max(0,AVAudioFramePosition(seconds*rate))) + anchor = Double(start)/rate; time = anchor + guard start < file.length else { playing = false; render(); return } + node.scheduleSegment(file,startingFrame:start,frameCount:AVAudioFrameCount(min(file.length-start,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack) { [weak self] _ in + Task { @MainActor in + guard let self, self.generation == token else { return } + if self.repeating { self.schedule(at:0,play:true) } + else { + self.playing = false; self.time = self.duration; self.render() + self.timer?.invalidate(); self.timer = nil; self.onFinished?() + } + } + } + if play { node.play() }; playing = play; render() + } + private func startTimer() { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval:1.0/30,repeats:true) { [weak self] _ in Task { @MainActor in self?.tick() } } + } + func toggle() { + guard file != nil else { return } + if playing { tick(); node.pause(); playing = false } + else if time >= duration { schedule(at:0,play:true); startTimer() } + else { node.play(); playing = true; startTimer() } + } + func stop() { + waveformTask?.cancel(); waveformTask = nil; waveformGeneration = UUID() + waveform = []; waveformBands = []; waveformLoading = false; waveformUnavailable = false + generation = UUID(); timer?.invalidate(); timer = nil; node.stop(); engine.stop() + if metering { engine.mainMixerNode.removeTap(onBus:0); metering = false } + meter.reset(); leftLevel = 0; rightLevel = 0 + engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect); engine.disconnectNodeOutput(tone) + file = nil; playing = false; time = 0; anchor = 0; duration = 0; image = nil; currentSongID = nil; title = "Select a song" + } + func seek(_ value: Double) { + guard file != nil, value.isFinite else { return } + schedule(at:min(max(value,0),duration),play:playing); startTimer() + } + private func render() { decoder.render(cdg,at:max(0,time+graphicsOffset)); image = cdg.isEmpty ? nil : decoder.image() } + private func tick() { + let levels = meter.snapshot() + leftLevel = playing ? levels.0 : 0; rightLevel = playing ? levels.1 : 0 + if playing, let file, let renderTime = node.lastRenderTime, let playerTime = node.playerTime(forNodeTime:renderTime) { + time = min(duration,max(0,anchor+Double(playerTime.sampleTime)/file.processingFormat.sampleRate)) + } + render() + } +} + +/// Streams fixed-size PCM chunks into a bounded RMS envelope, independent of playback. +/// A separate AVAudioFile keeps analysis away from the player's scheduling/read position. +enum AudioWaveform { + struct Analysis { var levels: [Float]; var bands: [WaveformBands] } + static func read(url: URL, bins: Int = 1024, cancelled: () -> Bool = { false }) throws -> [Float] { + try analyse(url:url,bins:bins,cancelled:cancelled).levels + } + static func analyse(url: URL, bins: Int = 1024, cancelled: () -> Bool = { false }) throws -> Analysis { + guard bins > 0 else { return .init(levels:[],bands:[]) } + let file = try AVAudioFile(forReading: url, commonFormat: .pcmFormatFloat32, interleaved: false) + guard file.length > 0, let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: 16384) else { return .init(levels:[],bands:[]) } + var energy = [Double](repeating: 0, count: bins) + var counts = [Int](repeating: 0, count: bins) + var position: Int64 = 0 + let channels = Int(file.processingFormat.channelCount) + let rate = file.processingFormat.sampleRate + let lowAlpha = exp(-2*Double.pi*250/rate), highAlpha = exp(-2*Double.pi*2500/rate) + var lowState = [Double](repeating:0,count:channels), highState = lowState + var lows = [Double](repeating:0,count:bins), mids = lows, highs = lows + while position < file.length { + if cancelled() { throw CancellationError() } + try file.read(into: buffer, frameCount: AVAudioFrameCount(min(16384, file.length-position))) + let frames = Int(buffer.frameLength) + guard frames > 0, let data = buffer.floatChannelData else { break } + for frame in 0.. 0 ? sqrt($0/Double($1)) : 0 } + let peak = rms.max() ?? 0 + let levels = peak > 0 ? rms.map { Float(pow(min(1,$0/peak),0.7)) } : [Float](repeating:0,count:bins) + let bands = (0.. (Float,Float) { lock.lock();defer { lock.unlock() };return levels } + func reset() { lock.lock();levels = (0,0);lock.unlock() } +} diff --git a/backups/before-classic-library-20261103-085620/KJamsCloneTests/KJamsCloneTests.swift b/backups/before-classic-library-20261103-085620/KJamsCloneTests/KJamsCloneTests.swift new file mode 100644 index 0000000..3348584 --- /dev/null +++ b/backups/before-classic-library-20261103-085620/KJamsCloneTests/KJamsCloneTests.swift @@ -0,0 +1,364 @@ +import XCTest +import AVFoundation +@testable import KJamsClone + +final class KJamsCloneTests: XCTestCase { + func packet(_ instruction: UInt8, _ data: [UInt8]) -> [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] } + func testPresetTileXORAndInvalidCoordinates() { + var d = CDGDecoder() + d.apply(packet(1,[3,0])); XCTAssertEqual(d.pixels[0],3) + d.apply(packet(6,[0,5,1,1] + Array(repeating:63,count:12))) + XCTAssertEqual(d.pixels[12*300+6],5) + d.apply(packet(38,[0,5,1,1] + Array(repeating:63,count:12))) + XCTAssertEqual(d.pixels[12*300+6],0) + let old = d.pixels; d.apply(packet(6,[1,2,31,63])); XCTAssertEqual(d.pixels,old) + } + func testSeekAndTruncatedPacket() { + var d = CDGDecoder() + let data = Data(packet(1,[2,0]) + packet(1,[7,0]) + [9]) + d.render(data,at:1); XCTAssertEqual(d.pixels[0],7); XCTAssertEqual(d.packetIndex,2) + d.render(data,at:1.0/300); XCTAssertEqual(d.pixels[0],2) + d.render(data,at:0); XCTAssertEqual(d.pixels[0],0) + } + func testPaletteBorderScrollAndImage() { + var d = CDGDecoder() + d.apply(packet(30,[63,63])); XCTAssertEqual(d.palette[0],0xFFFFFFFF) + d.apply(packet(2,[4])); XCTAssertEqual(d.pixels[0],4); XCTAssertEqual(d.pixels[12*300+6],0) + d.apply(packet(20,[3,16,0])); XCTAssertEqual(d.pixels[100*300],3) + d.apply(packet(24,[0,32,0])); XCTAssertEqual(d.pixels[100*300+294],3) + XCTAssertEqual(d.image()?.width,288) + } + func testSQLitePersistenceDedupRatingsAndHistory() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let url = folder.appendingPathComponent("test.sqlite") + do { + let db = try Library(url:url) + try db.add(title:"Singer's Song",audio:"/test.mp3",cdg:nil) + try db.add(title:"Duplicate",audio:"/test.mp3",cdg:"/test.cdg") + let song = try XCTUnwrap(db.songs().first) + try db.rate(song.id,5); XCTAssertThrowsError(try db.rate(song.id,6)) + try db.addEntity("singers",name:"O'Neil"); try db.addEntity("venues",name:"Hall") + try db.record(song.id,singer:db.entities("singers").first?.id,venue:db.entities("venues").first?.id) + } + let reopened = try Library(url:url); let songs = try reopened.songs() + XCTAssertEqual(songs.count,1); XCTAssertEqual(songs[0].rating,5); XCTAssertEqual(songs[0].plays,1); XCTAssertEqual(songs[0].cdg,"/test.cdg") + XCTAssertEqual(try reopened.entities("singers").first?.name,"O'Neil") + } + func testPairedImportAndContentDedup() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let audio = root.appendingPathComponent("Test.mp3"), cdg = root.appendingPathComponent("Test.CDG") + try Data([1,2,3]).write(to:audio); try Data(packet(1,[2,0])).write(to:cdg) + let db = try Library(url:root.appendingPathComponent("test.sqlite")) + let importer = Importer(media:root.appendingPathComponent("Media")) + try importer.run([audio],library:db); try importer.run([audio],library:db) + XCTAssertEqual(try db.songs().count,1) + XCTAssertNotNil(try db.songs().first?.cdg) + } + + func testZippedMP3GImportAndUnsafeArchiveRejection() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let db = try Library(url:folder.appendingPathComponent("db.sqlite")) + let importer = Importer(media:folder.appendingPathComponent("Media")) + try importer.run([fixtures.appendingPathComponent("Demo.zip")],library:db) + let song = try XCTUnwrap(db.songs().first) + XCTAssertEqual(song.title,"Demo"); XCTAssertNotNil(song.cdg) + XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio)) + XCTAssertThrowsError(try importer.run([fixtures.appendingPathComponent("Unsafe.zip")],library:db)) + } + @MainActor func testMP3PlaybackPauseAndSeek() throws { + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let player = Player() + defer { player.stop() } + try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0)) + XCTAssertGreaterThan(player.duration,3.9); XCTAssertTrue(player.playing) + player.toggle(); XCTAssertFalse(player.playing) + player.seek(2); XCTAssertEqual(player.time,2,accuracy:0.05); XCTAssertNotNil(player.image) + player.seek(0.1); XCTAssertEqual(player.time,0.1,accuracy:0.05) + } + @MainActor func testCreateSingerAndVenueThroughAppModel() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let model = AppModel(storageURL: root) + XCTAssertNil(model.error) + let singer = try model.createEntity(.singer, name: " Alex ") + let venue = try model.createEntity(.venue, name: "Town Hall") + XCTAssertEqual(model.singers.first?.name, "Alex") + XCTAssertEqual(model.venues.first?.name, "Town Hall") + XCTAssertEqual(try model.createEntity(.singer, name: "Alex"), singer) + XCTAssertEqual(model.singers.count, 1) + XCTAssertThrowsError(try model.createEntity(.venue, name: " ")) + let reopened = AppModel(storageURL: root) + XCTAssertEqual(reopened.singers.first?.id, singer) + XCTAssertEqual(reopened.venues.first?.id, venue) + } + + @MainActor func testVenuesShareSingersAndRememberSelection() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root) + let hall = try model.createEntity(.venue,name:"Hall") + let alex = try model.createEntity(.singer,name:"Alex") + let club = try model.createEntity(.venue,name:"Club") + XCTAssertTrue(model.venueSingers.isEmpty) + model.membership(singer:alex,included:true) + XCTAssertEqual(model.venueSingers.map(\.id),[alex]) + model.chooseVenue(hall); XCTAssertEqual(model.venueSingers.map(\.id),[alex]) + model.chooseVenue(club); model.membership(singer:alex,included:false) + XCTAssertTrue(model.venueSingers.isEmpty) + let reopened = AppModel(storageURL:root) + XCTAssertEqual(reopened.currentVenue,club); XCTAssertTrue(reopened.venueSingers.isEmpty) + reopened.chooseVenue(hall); XCTAssertEqual(reopened.venueSingers.map(\.id),[alex]) + XCTAssertEqual(reopened.singers.count,1) + } + func testMigrationAndMembershipRemovalDoesNotReappear() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let url = root.appendingPathComponent("db.sqlite") + var singer: Int64 = 0, venue: Int64 = 0 + do { + let db = try Library(url:url) + try db.add(title:"Song",audio:"/a.mp3",cdg:nil) + try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall") + singer = try XCTUnwrap(db.entities("singers").first?.id); venue = try XCTUnwrap(db.entities("venues").first?.id) + try db.record(XCTUnwrap(db.songs().first?.id),singer:singer,venue:venue) + try db.execute("PRAGMA user_version=1") + } + do { + let migrated = try Library(url:url) + XCTAssertEqual(try migrated.members(of:venue).map(\.id),[singer]) + try migrated.setMembership(singer:singer,venue:venue,included:false) + } + let reopened = try Library(url:url) + XCTAssertTrue(try reopened.members(of:venue).isEmpty) + XCTAssertEqual(try reopened.history(venue:venue).count,1) + } + func testQueuePlaylistsPreferencesAndDeletion() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let db = try Library(url:root.appendingPathComponent("db.sqlite")) + try db.add(title:"First",audio:"/1.mp3",cdg:nil) + try db.add(title:"Second",audio:"/2.mp3",cdg:nil) + let songs = try db.songs() + try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall"); try db.addEntity("venues",name:"Club") + let singer = try XCTUnwrap(db.entities("singers").first?.id) + let hall = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Hall" })?.id) + let club = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Club" })?.id) + XCTAssertThrowsError(try db.enqueue(song:songs[0].id,singer:singer,venue:hall)) + try db.setMembership(singer:singer,venue:hall,included:true) + try db.enqueue(song:songs[0].id,singer:singer,venue:hall); try db.enqueue(song:songs[1].id,singer:singer,venue:hall) + XCTAssertTrue(try db.queue(venue:club).isEmpty) + let second = try XCTUnwrap(db.queue(venue:hall).last) + try db.moveQueue(second.id,venue:hall,by:-1) + XCTAssertEqual(try db.queue(venue:hall).first?.songID,songs[1].id) + let preference = SongPreference(pitch:3,tempo:0.85,rating:4) + try db.savePreference(song:songs[0].id,singer:singer,value:preference) + XCTAssertEqual(try db.preference(song:songs[0].id,singer:singer),preference) + XCTAssertThrowsError(try db.savePreference(song:songs[0].id,singer:singer,value:.init(pitch:99))) + try db.addEntity("playlists",name:"Party") + let playlist = try XCTUnwrap(db.entities("playlists").first?.id) + try db.addToPlaylist(playlist,song:songs[0].id); try db.addToPlaylist(playlist,song:songs[0].id) + XCTAssertEqual(try db.playlistSongIDs(playlist),[songs[0].id]) + try db.record(songs[0].id,singer:singer,venue:hall) + try db.rename("singers",id:singer,name:"Alex Jones") + XCTAssertEqual(try db.history(venue:hall).first?.singer,"Alex Jones") + try db.deleteEntity("singers",id:singer) + XCTAssertTrue(try db.queue(venue:hall).isEmpty) + XCTAssertEqual(try db.history(venue:hall).count,1) + try db.selectVenue(hall); try db.deleteEntity("venues",id:hall) + XCTAssertNil(try db.currentVenue()); XCTAssertEqual(try db.history(venue:nil).count,1) + } + @MainActor func testTempoSourceClockPauseAndResume() async throws { + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let player = Player(); defer { player.stop() } + let song = Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0) + try player.load(song,preference:.init(pitch:2,tempo:1.5,rating:3)) + XCTAssertEqual(player.pitch,2); XCTAssertEqual(player.tempo,1.5) + try await Task.sleep(nanoseconds:500_000_000) + XCTAssertGreaterThan(player.time,0.45); XCTAssertLessThan(player.time,1.3) + player.toggle(); let paused = player.time + try await Task.sleep(nanoseconds:100_000_000) + XCTAssertEqual(player.time,paused,accuracy:0.025) + player.seek(1.5); player.toggle() + try await Task.sleep(nanoseconds:200_000_000) + XCTAssertGreaterThan(player.time,1.6); XCTAssertNotNil(player.image) + } + func testNewTestMediaMetadataAndCDGPairImport() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let db = try Library(url:root.appendingPathComponent("db.sqlite")) + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let importer = Importer(media:root.appendingPathComponent("Media")) + try importer.run([fixtures.appendingPathComponent("TestTrack.cdg")],library:db) + let song = try XCTUnwrap(db.songs().first) + XCTAssertEqual(song.title,"MP3 Plus G Test") + XCTAssertEqual(song.artist,"KJamsClone Test Generator") + XCTAssertNotNil(song.cdg) + let bytes = try Data(contentsOf:fixtures.appendingPathComponent("TestTrack.cdg")) + XCTAssertEqual(bytes.count,20*300*24) + var decoder = CDGDecoder(); decoder.render(bytes,at:2) + XCTAssertTrue(decoder.pixels.contains(1)); XCTAssertTrue(decoder.pixels.contains(2)) + } + @MainActor func testAutomaticVenueQueueAdvancesAndRecordsSinger() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root); defer { model.player.stop() } + _ = try model.createEntity(.venue,name:"Test Hall") + _ = try model.createEntity(.singer,name:"Alex") + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + model.importFiles([fixtures.appendingPathComponent("Demo.zip")]) + let song = try XCTUnwrap(model.songs.first) + model.enqueue(song.id); model.enqueue(song.id); model.playQueueNext() + XCTAssertEqual(model.queue.count,1); XCTAssertEqual(model.history.count,1) + model.player.seek(model.player.duration-0.1) + for _ in 0..<25 { + if model.queue.isEmpty { break } + try await Task.sleep(nanoseconds:100_000_000) + } + XCTAssertNil(model.error); XCTAssertTrue(model.queue.isEmpty) + XCTAssertEqual(model.history.count,2); XCTAssertEqual(model.history.first?.singer,"Alex") + } + func testWaveformRepresentsSilenceAndSignalInTimeOrder() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let url = folder.appendingPathComponent("signal.wav") + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:8000,channels:1)) + do { + let file = try AVAudioFile(forWriting:url,settings:format.settings) + let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:4096)); buffer.frameLength = 4096 + let samples = try XCTUnwrap(buffer.floatChannelData?[0]) + for i in 0..<4096 { samples[i] = i < 2048 ? 0 : Float(sin(Double(i)*0.2)*0.5) } + try file.write(from:buffer) + } + let envelope = try AudioWaveform.read(url:url,bins:32) + XCTAssertEqual(envelope.count,32) + XCTAssertTrue(envelope.prefix(16).allSatisfy { $0 == 0 }) + XCTAssertTrue(envelope.suffix(16).allSatisfy { $0 > 0.8 && $0 <= 1 }) + XCTAssertThrowsError(try AudioWaveform.read(url:url,cancelled:{true})) + } + @MainActor func testWaveformAnalysisAndStopClearsTrackData() async throws { + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let player = Player(); defer { player.stop() } + try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:nil,rating:0,plays:0)) + player.toggle() + for _ in 0..<50 { + if !player.waveformLoading { break } + try await Task.sleep(nanoseconds:20_000_000) + } + XCTAssertEqual(player.waveform.count,1024) + XCTAssertFalse(player.waveformUnavailable) + player.stop() + XCTAssertTrue(player.waveform.isEmpty) + XCTAssertFalse(player.waveformLoading) + try await Task.sleep(nanoseconds:50_000_000) + XCTAssertTrue(player.waveform.isEmpty) + } + + @MainActor func testConnectedSingerCreationAndSharedRepertoire() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root) + let hall = try model.createEntity(.venue,name:"Hall") + try model.library?.add(title:"Song",audio:"/sample.mp3",cdg:nil);try model.refresh() + let song = try XCTUnwrap(model.songs.first?.id) + let alex = try model.createSinger(name:" Alex ",venue:hall,songs:[song]) + XCTAssertEqual(model.selectedSinger,alex);XCTAssertEqual(model.currentVenue,hall) + XCTAssertEqual(model.venueSingers.map(\.id),[alex]);XCTAssertEqual(model.songs(for:alex).map(\.id),[song]) + XCTAssertThrowsError(try model.createSinger(name:"Broken",venue:hall,songs:[999999])) + XCTAssertFalse(try XCTUnwrap(model.library).entities("singers").contains { $0.name == "Broken" }) + let club = try model.createEntity(.venue,name:"Club");model.membership(singer:alex,included:true) + XCTAssertEqual(model.songs(for:alex).map(\.id),[song]) + model.assign(song,to:alex);XCTAssertEqual(model.songs(for:alex).count,1) + let reopened = AppModel(storageURL:root) + XCTAssertEqual(reopened.currentVenue,club);XCTAssertEqual(reopened.songs(for:alex).map(\.id),[song]) + reopened.assign(song,to:alex,included:false) + XCTAssertTrue(AppModel(storageURL:root).songs(for:alex).isEmpty) + } + func testQueueRemembersSingerSongWithoutDuplicatingAssignment() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let db = try Library(url:root.appendingPathComponent("db.sqlite")) + try db.addEntity("singers",name:"Alex");try db.addEntity("venues",name:"Hall") + try db.add(title:"Song",audio:"/a.mp3",cdg:nil) + let singer = try XCTUnwrap(db.entities("singers").first?.id),venue = try XCTUnwrap(db.entities("venues").first?.id),song = try XCTUnwrap(db.songs().first?.id) + try db.setMembership(singer:singer,venue:venue,included:true) + try db.enqueue(song:song,singer:singer,venue:venue);try db.enqueue(song:song,singer:singer,venue:venue) + XCTAssertEqual(try db.singerSongIDs()[singer],[song]);XCTAssertEqual(try db.queue(venue:venue).count,2) + try db.removeSingerSong(song,singer:singer) + XCTAssertNil(try db.singerSongIDs()[singer]);XCTAssertEqual(try db.songs().count,1) + } + func testFrequencyBandsDistinguishBassAndTreble() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let url = root.appendingPathComponent("bands.wav") + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:16000,channels:1)) + do { + let file = try AVAudioFile(forWriting:url,settings:format.settings) + let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:8192));buffer.frameLength = 8192 + for i in 0..<8192 { buffer.floatChannelData![0][i] = Float(sin(Double(i)*2*Double.pi*(i < 4096 ? 80 : 6000)/16000)*0.2) } + try file.write(from:buffer) + } + let analysis = try AudioWaveform.analyse(url:url,bins:32) + XCTAssertGreaterThan(analysis.bands[8].low,analysis.bands[8].high*3) + XCTAssertGreaterThan(analysis.bands[24].high,analysis.bands[24].low*3) + } + private func toneRMS(frequency: Double, low: Double = 0, filter: Double = 0, enabled: Bool = true) throws -> Double { + let engine = AVAudioEngine(),node = AVAudioPlayerNode(),eq = AVAudioUnitEQ(numberOfBands:4) + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:1)) + engine.attach(node);engine.attach(eq);engine.connect(node,to:eq,format:format);engine.connect(eq,to:engine.mainMixerNode,format:format) + AudioTone.configure(eq,sampleRate:44100,low:low,mid:0,high:0,filter:filter,enabled:enabled) + try engine.enableManualRenderingMode(.offline,format:format,maximumFrameCount:1024) + let input = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:32768));input.frameLength = 32768 + for i in 0..<32768 { input.floatChannelData![0][i] = Float(sin(Double(i)*2*Double.pi*frequency/44100)*0.1) } + node.scheduleBuffer(input);try engine.start();node.play();defer { node.stop();engine.stop() } + let output = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:1024)) + var energy: Double = 0,count = 0 + for block in 0..<12 { + let status = try engine.renderOffline(1024,to:output) + XCTAssertEqual(status,.success) + if block >= 2 { for i in 0.. + + + + + + \ No newline at end of file diff --git a/backups/before-kjmix-20261103-100337/KJamsClone/CDG.swift b/backups/before-kjmix-20261103-100337/KJamsClone/CDG.swift new file mode 100644 index 0000000..6b4625f --- /dev/null +++ b/backups/before-kjmix-20261103-100337/KJamsClone/CDG.swift @@ -0,0 +1,68 @@ +import Foundation +import CoreGraphics + +/// CD+G packet decoder. Packet parity is ignored; malformed coordinates are rejected. +struct CDGDecoder { + private(set) var pixels = [UInt8](repeating: 0, count: 300 * 216) + private(set) var palette = [UInt32](repeating: 0x000000FF, count: 16) + private(set) var packetIndex = 0 + private(set) var horizontalOffset = 0 + private(set) var verticalOffset = 0 + private var transparent: UInt8? + mutating func render(_ data: Data, at seconds: Double) { + let target = min(data.count / 24, max(0, Int(max(0, seconds) * 300))) + if target < packetIndex { self = CDGDecoder() } + while packetIndex < target { + apply(Array(data[(packetIndex * 24)..<(packetIndex * 24 + 24)])) + packetIndex += 1 + } + } + mutating func apply(_ packet: [UInt8]) { + guard packet.count == 24, packet[0] & 63 == 9 else { return } + let d = packet[4..<20].map { Int($0 & 63) } + switch packet[1] & 63 { + case 1: + if d[1] == 0 { pixels = .init(repeating: UInt8(d[0] & 15), count: pixels.count) } + case 2: + for y in 0..<216 { for x in 0..<300 where x < 6 || x >= 294 || y < 12 || y >= 204 { pixels[y * 300 + x] = UInt8(d[0] & 15) } } + case 6, 38: + let row = d[2] & 31, col = d[3] & 63 + guard row < 18, col < 50 else { return } + for y in 0..<12 { for x in 0..<6 { + let color = UInt8(d[(y + 4)] & (1 << (5 - x)) == 0 ? d[0] & 15 : d[1] & 15) + let i = (row * 12 + y) * 300 + col * 6 + x + if packet[1] & 63 == 38 { pixels[i] ^= color } else { pixels[i] = color } + } } + case 20, 24: + horizontalOffset = min(d[1] & 7, 5); verticalOffset = min(d[2] & 15, 11) + let hx = (d[1] >> 4) & 3, vy = (d[2] >> 4) & 3 + let dx = hx == 1 ? 6 : hx == 2 ? -6 : 0 + let dy = vy == 1 ? 12 : vy == 2 ? -12 : 0 + let old = pixels + for y in 0..<216 { for x in 0..<300 { + let sx = x - dx, sy = y - dy + if packet[1] & 63 == 24 || (sx >= 0 && sx < 300 && sy >= 0 && sy < 216) { + pixels[y * 300 + x] = old[((sy + 216) % 216) * 300 + (sx + 300) % 300] + } else { pixels[y * 300 + x] = UInt8(d[0] & 15) } + } } + case 28: transparent = UInt8(d[0] & 15) + case 30, 31: + let start = packet[1] & 63 == 30 ? 0 : 8 + for i in 0..<8 { + let v = (d[i * 2] << 6) | d[i * 2 + 1] + palette[start + i] = UInt32(((v >> 8) & 15) * 17) << 24 | UInt32(((v >> 4) & 15) * 17) << 16 | UInt32((v & 15) * 17) << 8 | 255 + } + default: break + } + } + func image() -> CGImage? { + var rgba = [UInt8](); rgba.reserveCapacity(288 * 192 * 4) + for y in 12..<204 { for x in 6..<294 { + let index = pixels[(y + verticalOffset) * 300 + x + horizontalOffset] + let c = palette[Int(index)] + rgba += [UInt8((c >> 24) & 255), UInt8((c >> 16) & 255), UInt8((c >> 8) & 255), index == transparent ? 0 : 255] + } } + guard let provider = CGDataProvider(data: Data(rgba) as CFData) else { return nil } + return CGImage(width: 288, height: 192, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: 288 * 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue), provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent) + } +} diff --git a/backups/before-kjmix-20261103-100337/KJamsClone/Importer.swift b/backups/before-kjmix-20261103-100337/KJamsClone/Importer.swift new file mode 100644 index 0000000..69e8fd7 --- /dev/null +++ b/backups/before-kjmix-20261103-100337/KJamsClone/Importer.swift @@ -0,0 +1,68 @@ +import Foundation +import CryptoKit +import AVFoundation + +struct Importer { + let media: URL + func run(_ urls: [URL], library: Library) throws { + try FileManager.default.createDirectory(at: media, withIntermediateDirectories: true) + var files = urls + var temporary = [URL]() + defer { for url in temporary { try? FileManager.default.removeItem(at: url) } } + for zip in urls where zip.pathExtension.lowercased() == "zip" { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + temporary.append(folder) + // Validate names before extraction; reject path traversal and symlinks afterwards. + let details = try process(["-Z", "-l", zip.path]) + guard !details.split(separator: "\n").contains(where: { $0.hasPrefix("l") }) else { throw LibraryError.message("ZIP symlinks are unsupported") } + let list = try process(["-Z1", zip.path]) + let names = list.split(separator: "\n").map(String.init) + guard !names.isEmpty, names.allSatisfy({ !$0.hasPrefix("/") && !$0.split(separator: "/").contains("..") && !$0.contains("\\") }) else { throw LibraryError.message("Unsafe or empty ZIP") } + _ = try process(["-q", zip.path, "-d", folder.path]) + let extracted = FileManager.default.enumerator(at: folder, includingPropertiesForKeys: [.isSymbolicLinkKey,.isRegularFileKey])! + for case let url as URL in extracted { + let values = try url.resourceValues(forKeys: [.isSymbolicLinkKey,.isRegularFileKey]) + guard values.isSymbolicLink != true else { throw LibraryError.message("ZIP symlinks are unsupported") } + if values.isRegularFile == true { files.append(url) } + } + } + for cdg in files.filter({ $0.pathExtension.lowercased() == "cdg" }) { + let siblings = try FileManager.default.contentsOfDirectory(at:cdg.deletingLastPathComponent(),includingPropertiesForKeys:nil) + if let audio = siblings.first(where: { ["mp3","wav","m4a"].contains($0.pathExtension.lowercased()) && $0.deletingPathExtension().lastPathComponent.caseInsensitiveCompare(cdg.deletingPathExtension().lastPathComponent) == .orderedSame }) { files.append(audio) } + } + var imported = 0 + for audio in Set(files) where ["mp3","wav","m4a"].contains(audio.pathExtension.lowercased()) { + let siblings = try FileManager.default.contentsOfDirectory(at: audio.deletingLastPathComponent(), includingPropertiesForKeys: nil) + let cdg = siblings.first { $0.pathExtension.lowercased() == "cdg" && $0.deletingPathExtension().lastPathComponent.caseInsensitiveCompare(audio.deletingPathExtension().lastPathComponent) == .orderedSame } + let hash = SHA256.hash(data: try Data(contentsOf: audio)).map { String(format: "%02x", $0) }.joined() + let dest = media.appendingPathComponent(hash + "." + audio.pathExtension.lowercased()) + if !FileManager.default.fileExists(atPath: dest.path) { try FileManager.default.copyItem(at: audio, to: dest) } + var graphics: String? + if let cdg { + let target = media.appendingPathComponent(hash + ".cdg") + if !FileManager.default.fileExists(atPath: target.path) { try FileManager.default.copyItem(at: cdg, to: target) } + graphics = target.path + } + imported += 1 + let metadata = AVURLAsset(url:audio).commonMetadata + func tag(_ key: AVMetadataKey) -> String { metadata.first { $0.commonKey == key }?.stringValue ?? "" } + let taggedTitle = tag(.commonKeyTitle) + try library.add(title:taggedTitle.isEmpty ? audio.deletingPathExtension().lastPathComponent : taggedTitle,audio:dest.path,cdg:graphics,artist:tag(.commonKeyArtist),album:tag(.commonKeyAlbumName),label:tag(.commonKeyPublisher),duration:audioDuration(audio)) + } + guard imported > 0 else { throw LibraryError.message("No audio found. Keep the MP3 and matching CDG together, or import an MP3+G ZIP.") } + } + private func audioDuration(_ url: URL) -> Double { + guard let file = try? AVAudioFile(forReading:url) else { return 0 } + return Double(file.length)/file.processingFormat.sampleRate + } + private func process(_ arguments: [String]) throws -> String { + let task = Process(); task.executableURL = URL(fileURLWithPath: "/usr/bin/unzip"); task.arguments = arguments + let out = Pipe(); task.standardOutput = out + let err = FileHandle.nullDevice; task.standardError = err + try task.run() + let bytes = out.fileHandleForReading.readDataToEndOfFile(); task.waitUntilExit() + guard task.terminationStatus == 0 else { throw LibraryError.message("Cannot read ZIP archive") } + return String(decoding: bytes, as: UTF8.self) + } +} diff --git a/backups/before-kjmix-20261103-100337/KJamsClone/KJamsCloneApp.swift b/backups/before-kjmix-20261103-100337/KJamsClone/KJamsCloneApp.swift new file mode 100644 index 0000000..ca835bb --- /dev/null +++ b/backups/before-kjmix-20261103-100337/KJamsClone/KJamsCloneApp.swift @@ -0,0 +1,1336 @@ +import SwiftUI +import UniformTypeIdentifiers +import AVFoundation + +enum EntityKind: String, Identifiable { + case singer, venue + var id: String { rawValue } + var title: String { self == .singer ? "Singer" : "Venue" } + var table: String { self == .singer ? "singers" : "venues" } +} + +/// Only an internal, same-process song reference is exported, never media or file URLs. +struct SongDragReference: Codable, Sendable { + static let type = UTType(exportedAs:"com.kjamsclone.internal-song") + var song: Int64 + var library: String + var singer: Int64? + var venue: Int64? + var fromTonight: Bool + func provider() -> NSItemProvider { + let provider = NSItemProvider() + let data = (try? JSONEncoder().encode(self)) ?? Data() + provider.registerDataRepresentation(forTypeIdentifier:Self.type.identifier,visibility:.ownProcess) { completion in completion(data,nil);return nil } + return provider + } + static func load(_ provider: NSItemProvider) async throws -> Self { + let data: Data = try await withCheckedThrowingContinuation { continuation in + provider.loadDataRepresentation(forTypeIdentifier:Self.type.identifier) { data,error in + if let data { continuation.resume(returning:data) } + else { continuation.resume(throwing:error ?? LibraryError.message("Could not read dragged song")) } + } + } + return try JSONDecoder().decode(Self.self,from:data) + } +} +@MainActor final class AppModel: ObservableObject { + @Published var songs = [Song]() + @Published var singers = [PersonPlace]() + @Published var singerSongIDs = [Int64:[Int64]]() + @Published var favourites = [Int64:Set]() + @Published var musicStores = [MusicStore]() + @Published var readingDurations = false + var newSingerSongs = Set() + private var songIndex = [Int64:Song]() + @Published var venues = [PersonPlace]() + @Published var venueSingers = [PersonPlace]() + @Published var playlists = [PersonPlace]() + @Published var queue = [QueueEntry]() + @Published var tonightSongIDs = [Int64:[Int64]]() + @Published var history = [Performance]() + @Published private(set) var currentVenue: Int64? + @Published var selectedSinger: Int64? + @Published var autoAdvance = true + @Published var error: String? + @Published var creating: EntityKind? + let player: Player + private(set) var library: Library? + let root: URL + var currentVenueName: String { venues.first { $0.id == currentVenue }?.name ?? "No venue selected" } + init(storageURL: URL? = nil) { + root = storageURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("KJamsClone") + player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms")) + perform { + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + library = try Library(url:root.appendingPathComponent("Library.sqlite")); try refresh() + } + player.onFinished = { [weak self] in + guard let self, self.autoAdvance, !self.queue.isEmpty else { return } + self.playQueueNext() + } + } + func perform(_ work: () throws -> Void) { do { try work() } catch { self.error = error.localizedDescription } } + func refresh() throws { + guard let library else { throw LibraryError.message("Library unavailable") } + songs = try library.songs();songIndex = Dictionary(uniqueKeysWithValues:songs.map { ($0.id,$0) }); singers = try library.entities("singers"); venues = try library.entities("venues") + singerSongIDs = try library.singerSongIDs();favourites = try library.favouriteSongIDs();musicStores = try library.stores() + playlists = try library.entities("playlists"); currentVenue = try library.currentVenue() + venueSingers = try currentVenue.map { try library.members(of:$0) } ?? singers + if let selectedSinger, !venueSingers.contains(where: { $0.id == selectedSinger }) { self.selectedSinger = nil } + tonightSongIDs = try library.tonightSongIDs(venue:currentVenue) + queue = try library.queue(venue:currentVenue); history = try library.history(venue:currentVenue) + } + func chooseVenue(_ id: Int64?) { + perform { + guard let library else { throw LibraryError.message("Library unavailable") } + try library.selectVenue(id); selectedSinger = nil; try refresh() + } + } + func membership(singer: Int64, included: Bool) { + perform { + guard let library, let currentVenue else { throw LibraryError.message("Select a current venue first") } + try library.setMembership(singer:singer,venue:currentVenue,included:included); try refresh() + } + } + @discardableResult func createEntity(_ kind: EntityKind, name: String) throws -> Int64 { + guard let library else { throw LibraryError.message("Library unavailable") } + let trimmed = name.trimmingCharacters(in:.whitespacesAndNewlines) + try library.addEntity(kind.table,name:trimmed) + guard let entity = try library.entities(kind.table).first(where: { $0.name == trimmed }) else { throw LibraryError.message("Could not load new entry") } + if kind == .singer { + if let currentVenue { try library.setMembership(singer:entity.id,venue:currentVenue,included:true) } + selectedSinger = entity.id + } else { try library.selectVenue(entity.id); selectedSinger = nil } + try refresh(); return entity.id + } + func beginSinger(song: Int64? = nil) { newSingerSongs = Set(song.map { [$0] } ?? []); creating = .singer } + func songs(for singer: Int64) -> [Song] { + (singerSongIDs[singer] ?? []).compactMap { songIndex[$0] } + } + func assign(_ song: Int64, to singer: Int64, included: Bool = true) { + perform { + guard let library else { throw LibraryError.message("Library unavailable") } + if included { try library.assignSong(song,singer:singer) } else { try library.removeSingerSong(song,singer:singer) } + try refresh() + } + } + func createSinger(name: String, venue: Int64, songs: Set) throws -> Int64 { + guard let library else { throw LibraryError.message("Library unavailable") } + guard venues.contains(where: { $0.id == venue }) else { throw LibraryError.message("Choose a venue first") } + let name = name.trimmingCharacters(in:.whitespacesAndNewlines) + try library.execute("BEGIN IMMEDIATE") + let id: Int64 + do { + try library.addEntity("singers",name:name) + guard let singer = try library.entities("singers").first(where: { $0.name == name }) else { throw LibraryError.message("Could not load singer") } + id = singer.id + try library.setMembership(singer:id,venue:venue,included:true) + for song in songs { try library.assignSong(song,singer:id) } + try library.selectVenue(venue);try library.execute("COMMIT") + } catch { try? library.execute("ROLLBACK");throw error } + try refresh();selectedSinger = id;return id + } + func rename(_ table: String, id: Int64, name: String) { perform { try library?.rename(table,id:id,name:name); try refresh() } } + func delete(_ table: String, id: Int64) { perform { try library?.deleteEntity(table,id:id); try refresh() } } + func preference(_ song: Int64) -> SongPreference { + guard let library else { return .init() } + if let selectedSinger { return (try? library.preference(song:song,singer:selectedSinger)) ?? .init() } + return (try? library.defaultPreference(song:song)) ?? .init() + } + func savePreference(song: Int64, value: SongPreference) { + perform { + guard let library, let selectedSinger else { throw LibraryError.message("Select a singer to save their song preferences") } + try library.savePreference(song:song,singer:selectedSinger,value:value); try refresh() + } + } + func play(_ song: Song, singer: Int64? = nil) throws { + guard let library else { throw LibraryError.message("Library unavailable") } + let singer = singer ?? selectedSinger + if let singer, let currentVenue, try !library.members(of:currentVenue).contains(where: { $0.id == singer }) { throw LibraryError.message("This singer is not in the current venue") } + let preference = try singer.map { try library.preference(song:song.id,singer:$0) } ?? library.defaultPreference(song:song.id) + try player.load(song,preference:preference) + do { try library.updateDuration(song.id,seconds:player.duration);try library.record(song.id,singer:singer,venue:currentVenue); selectedSinger = singer; try refresh() } + catch { player.stop(); throw error } + } + func enqueue(_ song: Int64) { perform { try library?.enqueue(song:song,singer:selectedSinger,venue:currentVenue); try refresh() } } + func tonightSongs(for singer: Int64) -> [Song] { (tonightSongIDs[singer] ?? []).compactMap { songIndex[$0] } } + func addToTonight(_ song: Int64,singer: Int64? = nil) { + perform { + guard let library,let currentVenue,let singer = singer ?? selectedSinger else { throw LibraryError.message("Choose a current venue and singer, then add the song to Tonight") } + try library.addTonight(song:song,singer:singer,venue:currentVenue);try refresh() + } + } + func removeFromTonight(_ song: Int64,singer: Int64) { + perform { guard let library,let currentVenue else { return };try library.removeTonight(song:song,singer:singer,venue:currentVenue);try refresh() } + } + func playTonight(_ song: Song,singer: Int64) { + perform { + try play(song,singer:singer) + if let entry = queue.first(where:{ $0.songID == song.id && $0.singerID == singer }) { try library?.removeQueue(entry.id) } + try refresh() + } + } + func dragReference(_ song: Int64,singer: Int64? = nil,tonight: Bool = false) -> SongDragReference { + .init(song:song,library:root.standardizedFileURL.path,singer:singer,venue:currentVenue,fromTonight:tonight) + } + func placeDroppedSong(_ reference: SongDragReference,singer: Int64,venue: Int64,before: Int64?) throws { + guard let library else { throw LibraryError.message("Library unavailable") } + guard reference.library == root.standardizedFileURL.path, songIndex[reference.song] != nil else { throw LibraryError.message("Drop a song from this library") } + guard currentVenue == venue else { throw LibraryError.message("The current venue changed. Please drop again.") } + let reorder = reference.fromTonight && reference.singer == singer && reference.venue == venue && tonightSongIDs[singer]?.contains(reference.song) == true + try library.placeTonight(song:reference.song,singer:singer,venue:venue,before:before,addIfNeeded:!reorder) + try refresh() + } + func acceptSongDrop(_ providers: [NSItemProvider],singer: Int64,venue: Int64?,before: Int64?) -> Bool { + guard let venue else { return false } + let providers = providers.filter { $0.hasItemConformingToTypeIdentifier(SongDragReference.type.identifier) } + guard !providers.isEmpty else { return false } + Task { @MainActor [weak self] in + guard let self else { return } + do { for provider in providers { let reference = try await SongDragReference.load(provider);try self.placeDroppedSong(reference,singer:singer,venue:venue,before:before) } } + catch { self.error = error.localizedDescription } + } + return true + } + func moveTonight(_ song: Int64,singer: Int64,by offset: Int) { + perform { + guard let library,let currentVenue,let ids = tonightSongIDs[singer],let index = ids.firstIndex(of:song),ids.indices.contains(index+offset) else { return } + let destination = index+offset + let before = offset < 0 ? ids[destination] : destination+1 < ids.count ? ids[destination+1] : nil + try library.placeTonight(song:song,singer:singer,venue:currentVenue,before:before,addIfNeeded:false);try refresh() + } + } + func playQueueNext() { + perform { + guard let row = queue.first, let song = songs.first(where: { $0.id == row.songID }) else { return } + // Explicitly clear selection for unassigned queue entries. + selectedSinger = row.singerID + try play(song,singer:row.singerID); try library?.removeQueue(row.id); try refresh() + } + } + func moveQueue(_ id: Int64, by offset: Int) { perform { if let currentVenue { try library?.moveQueue(id,venue:currentVenue,by:offset); try refresh() } } } + func removeQueue(_ id: Int64) { perform { try library?.removeQueue(id); try refresh() } } + func createPlaylist(_ name: String) { perform { try library?.addEntity("playlists",name:name); try refresh() } } + func addToPlaylist(_ playlist: Int64, song: Int64) { perform { try library?.addToPlaylist(playlist,song:song); try refresh() } } + func setFavourite(_ song: Int64, singer: Int64, included: Bool) { perform { try library?.setFavourite(song:song,singer:singer,included:included);try refresh() } } + func updateTuning(_ song: Int64, pitch: Double? = nil, tempo: Double? = nil) { + perform { + guard let library else { throw LibraryError.message("Library unavailable") } + var value = preference(song) + if let pitch { value.pitch = pitch };if let tempo { value.tempo = tempo } + if let selectedSinger { try library.savePreference(song:song,singer:selectedSinger,value:value) } + else { try library.saveDefaultPreference(song:song,value:value) } + if player.currentSongID == song { if pitch != nil { player.pitch = value.pitch };if tempo != nil { player.tempo = value.tempo } } + try refresh() + } + } + func saveStore(_ store: MusicStore) throws { guard let library else { throw LibraryError.message("Library unavailable") };try library.saveStore(store);try refresh() } + func deleteStore(_ id: Int64) { perform { try library?.deleteStore(id);try refresh() } } + func scanDurations() { + guard !readingDurations else { return };readingDurations = true + let songs = self.songs.filter { $0.duration == 0 } + Task.detached(priority:.utility) { [weak self] in + var durations = [(Int64,Double)]() + for song in songs { + if let file = try? AVAudioFile(forReading:URL(fileURLWithPath:song.audio)) { durations.append((song.id,Double(file.length)/file.processingFormat.sampleRate)) } + } + let completedDurations = durations + await MainActor.run { [weak self] in + guard let self else { return } + self.perform { for (id,value) in completedDurations { try self.library?.updateDuration(id,seconds:value) };try self.refresh() } + self.readingDurations = false + } + } + } + func updateMetadata(_ song: Song,title: String,artist: String,album: String,label: String,rating: Int) throws { + guard let library else { throw LibraryError.message("Library unavailable") } + try library.updateMetadata(song:song.id,title:title,artist:artist,album:album,label:label,rating:rating) + if player.currentSongID == song.id { player.title = title.trimmingCharacters(in:.whitespacesAndNewlines) } + try refresh() + } + func importFiles(_ urls: [URL]) { perform { guard let library else { throw LibraryError.message("Library unavailable") }; try Importer(media:root.appendingPathComponent("Media")).run(urls,library:library); try refresh() } } +} +@main struct KJamsCloneApp: App { + @StateObject private var model = AppModel() + var body: some Scene { + Window("KJamsClone", id: "main") { + LibraryView(model: model).frame(minWidth: 1060, minHeight: 680).preferredColorScheme(.dark) + }.defaultSize(width: 1260, height: 840) + .commands { + CommandGroup(replacing: .newItem) { + Button("New Singer…") { model.beginSinger() }.keyboardShortcut("n", modifiers: [.command, .shift]) + Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option]) + } + } + Window("Video", id: "lyrics") { + LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark) + }.defaultSize(width: 600, height: 400) + } +} +private enum Classic { + static let frame = Color(red: 0.15, green: 0.15, blue: 0.15) + static let navy = Color(red: 0.008, green: 0.065, blue: 0.16) + static let blue = Color(red: 0.015, green: 0.17, blue: 0.42) + static let header = Color(white: 0.115) + static let line = Color(white: 0.42) +} +struct LyricsView: View { + @ObservedObject var player: Player + var body: some View { + ZStack { + Color.black + if let image = player.image { + Image(decorative: image, scale: 1).interpolation(.none).resizable().aspectRatio(1.5, contentMode: .fit) + } + } + } +} +/// This is another view of the same published CGImage used by the Video window. +/// It owns no decoder, audio player or clock, so seeking/pause/tempo stay identical. +private struct SidebarVideoMonitor: View { + let player: Player + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Label("Video Monitor", systemImage: "tv") + .font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7)) + LyricsView(player: player) + .aspectRatio(1.5, contentMode: .fit) + .clipped() + .overlay(Rectangle().strokeBorder(.white.opacity(0.25), lineWidth: 1)) + .accessibilityLabel("Video monitor, synchronized with the Video window") + }.frame(maxWidth: .infinity) + } +} +private struct SilverSquareStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label.foregroundStyle(Color(white: 0.25)).padding(.horizontal, 6).padding(.vertical, 3) + .background(LinearGradient(colors: [Color(white: 0.95), Color(white: 0.64)], startPoint: .top, endPoint: .bottom), in: RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Color(white: 0.45), lineWidth: 1.5)) + .overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(.white.opacity(0.7)).padding(3)) + .opacity(configuration.isPressed ? 0.7 : 1) + } +} +private struct SilverButton: View { + let symbol: String + let label: String + var size: CGFloat = 40 + var active = false + var enabled = true + let action: () -> Void + var body: some View { + Button(action: action) { + Image(systemName: symbol).font(.system(size: size * 0.41, weight: .semibold)) + .foregroundStyle(active ? Color.blue : Color(white: 0.24)) + .frame(width: size, height: size) + .background(LinearGradient(colors: [.white, Color(white: 0.85), Color(white: 0.63)], startPoint: .top, endPoint: .bottom), in: Circle()) + .overlay(Circle().strokeBorder(Color(white: 0.45), lineWidth: 1.5)) + .overlay(Circle().strokeBorder(.white.opacity(0.65), lineWidth: 1).padding(3)) + .shadow(color: .black.opacity(0.7), radius: 1, y: 1) + }.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.72) + .help(label).accessibilityLabel(label) + } +} +struct LibraryView: View { + @ObservedObject var model: AppModel + @State private var selected: Int64? + @State private var search = "" + @State private var name = "" + @State private var importing = false + @State private var source = "Library" + @State private var singersExpanded = true + @State private var expandedSingers = Set() + @State private var expandedSongLists = Set() + @State private var songPickerSinger: PersonPlace? + @State private var songPickerTonight = false + @State private var singerSections = [Int64:String]() + @State private var singerSelections = [Int64:Int64]() + @State private var presentedSinger: Int64? + @State private var singerSection = "All Songs" + @State private var storesExpanded = true + @State private var focusedStore: Int64? + @State private var editingSong: Song? + @State private var librarySort = "Song Name" + @State private var sortAscending = true + @Environment(\.openWindow) private var openWindow + private var visibleSongs: [Song] { + let filtered = model.songs.filter { search.isEmpty || ($0.title+" "+$0.artist+" "+$0.album).localizedCaseInsensitiveContains(search) } + return filtered.sorted { a,b in + if a.id == b.id { return false } + if librarySort == "Duration" { return a.duration == b.duration ? a.id < b.id : sortAscending ? a.duration < b.duration : a.duration > b.duration } + if librarySort == "Rating" { return a.rating == b.rating ? a.id < b.id : sortAscending ? a.rating < b.rating : a.rating > b.rating } + let first = librarySort == "Artist" ? a.artist : librarySort == "Album" ? a.album : a.title + let second = librarySort == "Artist" ? b.artist : librarySort == "Album" ? b.album : b.title + let order = first.localizedStandardCompare(second) + return order == .orderedSame ? a.id < b.id : sortAscending ? order == .orderedAscending : order == .orderedDescending + } + } + + private var selectedSong: Song? { model.songs.first { $0.id == selected } } + var body: some View { + VStack(spacing: 0) { + TransportView(player: model.player, search: $search, hasSelection: selectedSong != nil, + play: playSelected, next: playNext, display: { openWindow(id: "lyrics") }) + .padding(.horizontal, 18).padding(.top, 18).padding(.bottom, 22) + HSplitView { + sidebar.frame(minWidth: 190, idealWidth: 220, maxWidth: 240) + VStack(spacing: 12) { + if source == "Library" { songTable } + else if source == "Queue" { queuePane } + else if source == "History" { historyPane } + else if source == "Playlists" { playlistPane } + else if source == "Singer Songs" { singerSongPane } + else if source == "Music Stores" { MusicStoresPane(model:model,selected:$focusedStore) } + else { directory } + detailPanel.frame(height: 135) + }.padding(.leading, 12).frame(minWidth: 780) + }.padding(.horizontal, 18) + footer.padding(.horizontal, 18).padding(.vertical, 12) + } + .background(Classic.frame) + .sheet(item: $model.creating) { kind in + AddEntitySheet(model: model, kind: kind) { id in + if kind == .singer { expandedSingers.insert(id);expandedSongLists.insert(id);singersExpanded = true;showSinger(id,section:"All Songs") } else { model.chooseVenue(id) } + } + } + .sheet(item:$editingSong) { song in SongMetadataSheet(model:model,song:song) } + .sheet(item:$songPickerSinger) { singer in SingerSongsSheet(model:model,singer:singer,tonight:songPickerTonight) } + .onChange(of:selected) { song in + if source == "Singer Songs",let singer = model.selectedSinger,let song { singerSelections[singer] = song } + } + .onChange(of:model.selectedSinger) { singer in + if source == "Singer Songs",presentedSinger != singer { showSinger(singer,section:nil) } + } + .fileImporter(isPresented: $importing, allowedContentTypes: [.audio, .zip, UTType(filenameExtension: "cdg") ?? .data], allowsMultipleSelection: true) { result in + switch result { + case .success(let urls): model.importFiles(urls) + case .failure(let error): model.error = error.localizedDescription + } + } + .alert("KJamsClone", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { + Button("OK") { model.error = nil } + } message: { Text(model.error ?? "") } + } + private func showSinger(_ id: Int64?,section: String? = nil) { + if source == "Singer Songs",let previous = presentedSinger { + singerSections[previous] = singerSection + if let selected { singerSelections[previous] = selected } + } + presentedSinger = id;model.selectedSinger = id + singerSection = section ?? id.flatMap { singerSections[$0] } ?? "Tonight" + if let id { singerSections[id] = singerSection;selected = singerSelections[id] } + source = "Singer Songs" + } + private func sourceCount(_ source: String) -> Int { + switch source { + case "Library": return model.songs.count + case "Singers": return model.venueSingers.count + case "Venues": return model.venues.count + case "Queue": return model.queue.count + case "History": return model.history.count + default: return model.playlists.count + } + } + private var sidebar: some View { + VStack(alignment:.leading,spacing:0) { + Text("Source").font(.system(size:15,weight:.semibold)).padding(.horizontal,10).frame(height:31) + .frame(maxWidth:.infinity,alignment:.leading).background(Classic.header) + ScrollView { + VStack(spacing:0) { + sourceRow("Library",icon:"music.note.house.fill",color:.yellow) + HStack(spacing:5) { + Button { singersExpanded.toggle() } label: { Image(systemName:singersExpanded ? "chevron.down" : "chevron.right").font(.system(size:10)).frame(width:12) }.buttonStyle(.plain).accessibilityLabel("Expand singers") + Button { source = "Singers" } label: { HStack { Image(systemName:"person.2.fill").foregroundStyle(.green);Text("Singers");Spacer();Text("\(model.venueSingers.count)").font(.caption).foregroundStyle(.secondary) } }.buttonStyle(.plain) + Button { model.beginSinger() } label: { Image(systemName:"plus.circle.fill").foregroundStyle(.green) }.buttonStyle(.plain).help("New singer and songs") + }.padding(.horizontal,10).frame(height:34).background(source == "Singers" ? Color.blue.opacity(0.65) : .clear) + if singersExpanded { + Text(model.currentVenueName).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1).padding(.leading,30).frame(maxWidth:.infinity,alignment:.leading).padding(.vertical,4) + ForEach(model.venueSingers) { singer in sidebarSinger(singer) } + if model.venueSingers.isEmpty { Text("Add a singer with +").font(.caption).foregroundStyle(.secondary).padding(8) } + } + HStack { + Button { storesExpanded.toggle() } label: { Image(systemName:storesExpanded ? "chevron.down" : "chevron.right").font(.system(size:10)) }.buttonStyle(.plain) + Button { source = "Music Stores" } label: { Label("Music Stores",systemImage:"bag.fill").foregroundStyle(.green);Spacer() }.buttonStyle(.plain) + }.padding(.horizontal,12).frame(height:34) + if storesExpanded { + ForEach(model.musicStores) { store in + Button { focusedStore = store.id;source = "Music Stores" } label: { Label(store.name,systemImage:"globe").font(.system(size:12)).lineLimit(1).padding(.leading,32).frame(maxWidth:.infinity,alignment:.leading).frame(height:26) }.buttonStyle(.plain) + } + Button("Configure Stores…") { source = "Music Stores" }.buttonStyle(.plain).font(.system(size:11)).foregroundStyle(.secondary).padding(.leading,32).frame(maxWidth:.infinity,alignment:.leading).frame(height:25) + } + sourceRow("Venues",icon:"building.2.fill",color:.cyan) + sourceRow("Queue",icon:"list.number",color:.orange) + sourceRow("History",icon:"clock.arrow.circlepath",color:.purple) + sourceRow("Playlists",icon:"music.note.list",color:.mint) + } + } + SidebarVideoMonitor(player:model.player).padding(10) + }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line,lineWidth:1)) + } + private func sourceRow(_ title: String, icon: String, color: Color) -> some View { + Button { source = title } label: { + HStack(spacing:10) { + Image(systemName:icon).foregroundStyle(color).frame(width:22) + Text(title).font(.system(size:16,weight:.medium));Spacer() + Text("\(sourceCount(title))").font(.system(size:11)).foregroundStyle(.white.opacity(0.6)) + }.padding(.horizontal,12).frame(height:34).contentShape(Rectangle()) + .background(source == title ? Color(red:0,green:0.36,blue:1) : .clear) + }.buttonStyle(.plain) + } + private func sidebarSinger(_ singer: PersonPlace) -> some View { + VStack(alignment:.leading,spacing:0) { + HStack(spacing:5) { + Button { + if expandedSingers.contains(singer.id) { expandedSingers.remove(singer.id) } else { expandedSingers.insert(singer.id) } + } label: { Image(systemName:expandedSingers.contains(singer.id) ? "chevron.down" : "chevron.right").font(.system(size:9)).frame(width:12) }.buttonStyle(.plain).accessibilityLabel("Expand \(singer.name)’s songs") + Button { showSinger(singer.id) } label: { + HStack(spacing:5) { Text(singer.name).lineLimit(1);Spacer();Text("\(model.singerSongIDs[singer.id]?.count ?? 0)").font(.system(size:10)).foregroundStyle(.secondary) } + }.buttonStyle(.plain) + Menu { + Button("Add to Tonight…") { showSinger(singer.id,section:"Tonight");songPickerTonight = true;songPickerSinger = singer } + Button("Manage All Songs…") { showSinger(singer.id,section:"All Songs");songPickerTonight = false;songPickerSinger = singer } + } label: { Image(systemName:"plus").font(.system(size:10,weight:.bold)) }.menuStyle(.borderlessButton).frame(width:16).help("Add songs to \(singer.name)’s Tonight or repertoire") + }.font(.system(size:13,weight:.medium)).padding(.leading,25).padding(.trailing,12).frame(height:29) + .background(model.selectedSinger == singer.id && source == "Singer Songs" ? Color.blue.opacity(0.25) : .clear) + .modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:nil,enabled:true)) + if expandedSingers.contains(singer.id) { + singerFolder("Tonight",icon:"music.note.list",singer:singer) + singerFolder("Favourites",icon:"star.fill",singer:singer) + singerFolder("History",icon:"clock.arrow.circlepath",singer:singer) + HStack(spacing:3) { + Button { if expandedSongLists.contains(singer.id) { expandedSongLists.remove(singer.id) } else { expandedSongLists.insert(singer.id) } } label: { Image(systemName:expandedSongLists.contains(singer.id) ? "chevron.down" : "chevron.right").font(.system(size:9)) }.buttonStyle(.plain) + singerFolder("All Songs",icon:"folder",singer:singer) + }.padding(.leading,28) + if expandedSongLists.contains(singer.id) { + ForEach(model.songs(for:singer.id)) { song in + Button { showSinger(singer.id,section:"All Songs");selected = song.id;singerSelections[singer.id] = song.id } label: { + Label(song.title,systemImage:"music.note").font(.system(size:11)).lineLimit(1).frame(maxWidth:.infinity,alignment:.leading).padding(.leading,44).padding(.trailing,8).frame(height:25) + }.buttonStyle(.plain) + .onDrag { model.dragReference(song.id).provider() } + .contextMenu { + Button("Play for \(singer.name)") { model.selectedSinger = singer.id;model.perform { try model.play(song,singer:singer.id) } } + Button("Queue for \(singer.name)") { model.selectedSinger = singer.id;model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Button(model.favourites[singer.id]?.contains(song.id) == true ? "Remove Favourite" : "Add to Favourites") { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } + Button("Remove from Singer",role:.destructive) { model.assign(song.id,to:singer.id,included:false) } + } + } + if model.songs(for:singer.id).isEmpty { Text("No songs · use +").font(.system(size:10)).foregroundStyle(.secondary).padding(.leading,44).padding(.vertical,5) } + } + } + } + } + private func singerFolder(_ title: String, icon: String, singer: PersonPlace) -> some View { + Button { showSinger(singer.id,section:title) } label: { + Label { Text(title + (title == "Tonight" ? " (\(model.tonightSongIDs[singer.id]?.count ?? 0))" : "")) } icon: { if title == "Favourites" { GoldStar(filled:true,size:12) } else { Image(systemName:icon) } }.font(.system(size:11)).foregroundStyle(title == "Favourites" ? Color(red:1,green:0.75,blue:0.16) : Color.white.opacity(0.75)).padding(.leading,title == "All Songs" ? 0 : 43).frame(maxWidth:.infinity,alignment:.leading).frame(height:25) + }.buttonStyle(.plain) + .modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:nil,enabled:title == "Tonight")) + } + private var singerSongPane: some View { + VStack(alignment:.leading,spacing:12) { + if let singer = model.singers.first(where:{ $0.id == model.selectedSinger }) { + HStack { Text("\(singer.name) · \(singerSection)").font(.headline);Spacer();Button(singerSection == "Tonight" ? "Add to Tonight…" : "Add Songs…") { songPickerTonight = singerSection == "Tonight";songPickerSinger = singer } } + Text("\(model.currentVenueName) · Singer song lists follow them across venues.").font(.caption).foregroundStyle(.secondary) + if singerSection == "History" { + ScrollView { LazyVStack(alignment:.leading,spacing:12) { ForEach(model.history.filter { $0.singerID == singer.id }) { row in HStack { Text(row.date).font(.caption.monospaced());Text(row.song);Spacer() } } } } + } else if singerSection == "Tonight" { + ScrollView { + VStack(spacing:10) { + ForEach(Array(model.tonightSongs(for:singer.id).enumerated()),id:\.element.id) { index,song in + HStack { + Image(systemName:"line.3.horizontal").foregroundStyle(.secondary).help("Drag to reorder Tonight") + Text("\(index+1)").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width:18) + Button { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } label: { GoldStar(filled:model.favourites[singer.id]?.contains(song.id) == true,size:15) }.buttonStyle(.plain).help("Toggle favourite") + Text(song.title).lineLimit(1);Spacer() + Text(model.queue.contains(where:{ $0.songID == song.id && $0.singerID == singer.id }) ? "Queued" : "Ready").font(.caption).foregroundStyle(.secondary) + Button("Play") { model.playTonight(song,singer:singer.id) } + Button("Queue Again") { model.selectedSinger = singer.id;model.enqueue(song.id) } + Button { model.moveTonight(song.id,singer:singer.id,by:-1) } label: { Image(systemName:"arrow.up") }.disabled(index == 0).help("Move earlier") + Button { model.moveTonight(song.id,singer:singer.id,by:1) } label: { Image(systemName:"arrow.down") }.disabled(index == model.tonightSongs(for:singer.id).count-1).help("Move later") + Button("Remove") { model.removeFromTonight(song.id,singer:singer.id) } + }.padding(8).background(.white.opacity(0.035)).contentShape(Rectangle()) + .onDrag { model.dragReference(song.id,singer:singer.id,tonight:true).provider() } + .modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:song.id,enabled:true)) + } + Text(model.tonightSongs(for:singer.id).isEmpty ? "Drop a library song here to add it to \(singer.name)’s Tonight." : "Drop here to append · Drop on a row to place before it") + .font(.callout).foregroundStyle(.secondary).frame(maxWidth:.infinity).padding(18) + .overlay(RoundedRectangle(cornerRadius:4).strokeBorder(.white.opacity(0.16),style:StrokeStyle(lineWidth:1,dash:[5,4]))) + .modifier(TonightSongDropTarget(model:model,singer:singer.id,venue:model.currentVenue,before:nil,enabled:true)) + } + } + } else { + ScrollView { + VStack(spacing:10) { + ForEach(model.songs(for:singer.id).filter { singerSection != "Favourites" || model.favourites[singer.id]?.contains($0.id) == true }) { song in + HStack { + Button { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } label: { GoldStar(filled:model.favourites[singer.id]?.contains(song.id) == true,size:15) }.buttonStyle(.plain).help("Favourite for this singer") + Text(song.title).lineLimit(1);Spacer() + Button("Play") { model.selectedSinger = singer.id;model.perform { try model.play(song,singer:singer.id) } } + Button("Add to Tonight") { model.addToTonight(song.id,singer:singer.id) }.disabled(model.currentVenue == nil) + Button("Remove") { model.assign(song.id,to:singer.id,included:false) } + }.padding(8).background(.white.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = song.id } + .onDrag { model.dragReference(song.id).provider() } + } + if model.songs(for:singer.id).isEmpty { Text("Use Add Songs… or the + beside this singer in the sidebar.").foregroundStyle(.secondary).padding(.top,25) } + } + } + } + } else { Text("Choose a singer from the sidebar.").foregroundStyle(.secondary);Spacer() } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + private var songTable: some View { + GeometryReader { geometry in + ScrollView(.horizontal) { + VStack(spacing: 0) { + tableHeader(width: max(geometry.size.width, 960)) + ScrollView(.vertical) { + LazyVStack(spacing: 0) { + ForEach(visibleSongs) { song in songRow(song, width: max(geometry.size.width, 960)) } + }.frame(maxWidth: .infinity, alignment: .topLeading) + } + }.frame(width: max(geometry.size.width, 960), height: geometry.size.height) + } + }.background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) + } + private func widths(_ total: CGFloat) -> [CGFloat] { [44,max(220,total-600),110,100,65,82,90,109] } + private func tableHeader(width: CGFloat) -> some View { + let sizes = widths(width) + return HStack(spacing:0) { + ForEach(Array(["ID","Song Name","Artist","Album","Duration","Pitch","Tempo","Rating"].enumerated()),id:\.offset) { index,title in + Button { + guard ["Song Name","Artist","Album","Duration","Rating"].contains(title) else { return } + if librarySort == title { sortAscending.toggle() } else { librarySort = title;sortAscending = true } + } label: { HStack(spacing:3) { Text(title);if librarySort == title { Image(systemName:sortAscending ? "chevron.up" : "chevron.down").font(.system(size:8)) } }.font(.system(size:12,weight:.medium)).lineLimit(1).padding(.leading,7).frame(width:sizes[index],height:30,alignment:.leading) } + .buttonStyle(.plain).overlay(alignment:.trailing) { Rectangle().fill(Classic.line).frame(width:1).padding(.vertical,4) } + } + }.foregroundStyle(.white).background(Classic.header).overlay(alignment:.bottom) { Rectangle().fill(Color(white:0.65)).frame(height:1) } + } + private func durationLabel(_ seconds: Double) -> String { seconds > 0 ? String(format:"%d:%02d",Int(seconds)/60,Int(seconds)%60) : "—" } + private func songRow(_ song: Song,width: CGFloat) -> some View { + let sizes = widths(width) + return HStack(spacing:0) { + Text("\(song.id)").foregroundStyle(.secondary).frame(width:sizes[0]) + Text(song.title).lineLimit(1).padding(.leading,7).frame(width:sizes[1],alignment:.leading) + Text(song.artist.isEmpty ? "—" : song.artist).foregroundStyle(.secondary).lineLimit(1).padding(.leading,7).frame(width:sizes[2],alignment:.leading) + Text(song.album.isEmpty ? "—" : song.album).foregroundStyle(.secondary).lineLimit(1).padding(.leading,7).frame(width:sizes[3],alignment:.leading) + Text(durationLabel(song.duration)).font(.system(size:11,design:.monospaced)).frame(width:sizes[4]) + HStack(spacing:2) { + Text(String(format:"%+.0f",model.preference(song.id).pitch)).font(.system(size:11,design:.monospaced)).frame(width:30) + Stepper("Pitch",value:Binding(get:{ model.preference(song.id).pitch },set:{ model.updateTuning(song.id,pitch:$0) }),in:-12...12,step:1).labelsHidden().controlSize(.mini).fixedSize() + }.frame(width:sizes[5]).help("Saved pitch; applies to the selected singer, or song default") + Picker("Tempo",selection:Binding(get:{ model.preference(song.id).tempo },set:{ model.updateTuning(song.id,tempo:$0) })) { + ForEach(10..<31) { Text("\($0*5)%").tag(Double($0)/20) } + }.labelsHidden().controlSize(.mini).frame(width:sizes[6]-8).padding(.horizontal,4).help("Saved tempo; applies live if this song is playing") + GoldRating(rating:Binding(get:{ song.rating },set:{ value in model.perform { try model.library?.rate(song.id,value);try model.refresh() } })).frame(width:sizes[7]) + }.font(.system(size:12)).foregroundStyle(.white).frame(height:30) + .background(selected == song.id ? Color.blue.opacity(0.5) : song.id % 2 == 0 ? Color.white.opacity(0.025) : .clear) + .contentShape(Rectangle()).onTapGesture(count:2) { selected = song.id;playSelected() }.onTapGesture { selected = song.id } + .onDrag { model.dragReference(song.id).provider() } + .contextMenu { + Button("Play") { selected = song.id; playSelected() } + Button("Edit Metadata…") { editingSong = song } + Menu("Add to Singer") { + ForEach(model.venueSingers) { singer in Button(singer.name) { model.assign(song.id,to:singer.id);model.selectedSinger = singer.id;expandedSingers.insert(singer.id) } } + Divider();Button("New Singer + This Song…") { model.beginSinger(song:song.id) } + } + Menu("Add to Tonight") { ForEach(model.venueSingers) { singer in Button(singer.name) { model.addToTonight(song.id,singer:singer.id) } } }.disabled(model.currentVenue == nil) + Button("Add to Current Venue Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Menu("Add to Playlist") { ForEach(model.playlists) { playlist in Button(playlist.name) { model.addToPlaylist(playlist.id,song:song.id) } } } + } + } + @State private var managingRoster = false + @State private var rosterNewName = "" + @State private var editingEntity: PersonPlace? + @State private var renameText = "" + @State private var pendingDelete: PersonPlace? + private var directory: some View { + VStack(alignment:.leading,spacing:0) { + HStack { + Text(source == "Singers" ? "Singers · \(model.currentVenueName)" : "Venues").font(.headline) + Spacer() + if source == "Singers" { Button("Manage Venue Singers…") { managingRoster = true }.disabled(model.currentVenue == nil) } + Button(source == "Singers" ? "New Singer…" : "New Venue…") { if source == "Singers" { model.beginSinger() } else { model.creating = .venue } } + }.padding(12).background(Classic.header) + ScrollView { + VStack(alignment:.leading,spacing:0) { + if source == "Singers" && model.venueSingers.isEmpty { + Text("No singers at this venue. Add a singer or use Manage Venue Singers to include existing singers.").foregroundStyle(.secondary).padding() + } + ForEach(source == "Singers" ? model.venueSingers : model.venues) { entity in + HStack { + Button { + if source == "Singers" { model.selectedSinger = entity.id } else { model.chooseVenue(entity.id) } + } label: { + HStack { + Image(systemName:source == "Singers" ? "person.fill" : "building.2.fill") + Text(entity.name); Spacer() + if entity.id == (source == "Singers" ? model.selectedSinger : model.currentVenue) { + Text(source == "Singers" ? "Selected" : "Current venue").font(.caption).foregroundStyle(.cyan) + Image(systemName:"checkmark.circle.fill").foregroundStyle(.cyan) + } + }.contentShape(Rectangle()) + }.buttonStyle(.plain) + Button("Rename") { renameText = entity.name; editingEntity = entity } + Button("Delete",role:.destructive) { pendingDelete = entity } + }.padding(12) + } + } + } + HStack { + TextField(source == "Singers" ? "Singer name" : "Venue name",text:$name).textFieldStyle(.roundedBorder).onSubmit(addEntity) + Button(source == "Singers" ? "Add Singer" : "Add Venue",action:addEntity).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + }.padding(12) + }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + .sheet(isPresented:$managingRoster) { rosterSheet } + .sheet(item:$editingEntity) { entity in + VStack(alignment:.leading,spacing:16) { + Text("Rename \(source == "Singers" ? "Singer" : "Venue")").font(.headline) + TextField("Name",text:$renameText).textFieldStyle(.roundedBorder) + HStack { Button("Cancel") { editingEntity = nil }; Spacer(); Button("Save") { model.rename(source == "Singers" ? "singers" : "venues",id:entity.id,name:renameText); editingEntity = nil }.disabled(renameText.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) } + }.padding(24).frame(width:360) + } + .confirmationDialog("Delete \(pendingDelete?.name ?? "entry")? Historical plays are kept. Venue memberships and queued entries for it will be removed.",isPresented:Binding(get:{ pendingDelete != nil },set:{ if !$0 { pendingDelete = nil } })) { + Button("Delete",role:.destructive) { if let entity = pendingDelete { model.delete(source == "Singers" ? "singers" : "venues",id:entity.id) }; pendingDelete = nil } + } + } + private var rosterSheet: some View { + VStack(alignment:.leading,spacing:16) { + Text("Singers at \(model.currentVenueName)").font(.title2.bold()) + Text("A singer can belong to any number of venues. Unchecking removes them from this venue only.").foregroundStyle(.secondary) + ScrollView { + VStack(alignment:.leading,spacing:12) { + ForEach(model.singers) { singer in + Toggle(singer.name,isOn:Binding(get:{ model.venueSingers.contains { $0.id == singer.id } },set:{ model.membership(singer:singer.id,included:$0) })) + } + }.frame(maxWidth:.infinity,alignment:.leading) + }.frame(minHeight:120,maxHeight:280) + HStack { + TextField("New singer name",text:$rosterNewName).textFieldStyle(.roundedBorder) + Button("Add") { model.perform { _ = try model.createEntity(.singer,name:rosterNewName); rosterNewName = "" } }.disabled(rosterNewName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + Spacer(); Button("Done") { managingRoster = false }.keyboardShortcut(.defaultAction) + } + }.padding(24).frame(width:460) + } + private var detailPanel: some View { + VStack(alignment:.leading,spacing:9) { + HStack(spacing:10) { + Picker("Current venue",selection:Binding(get:{ model.currentVenue },set:{ model.chooseVenue($0) })) { + Text("Select venue…").tag(nil as Int64?) + ForEach(model.venues) { Text($0.name).tag(Optional($0.id)) } + } + Button { model.creating = .venue } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add venue") + Picker("Singer",selection:$model.selectedSinger) { + Text("None").tag(nil as Int64?) + ForEach(model.venueSingers) { Text($0.name).tag(Optional($0.id)) } + } + Button { model.beginSinger() } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add singer") + Menu("Add to Singer") { + ForEach(model.venueSingers) { singer in Button(singer.name) { if let selected { model.assign(selected,to:singer.id);model.selectedSinger = singer.id;expandedSingers.insert(singer.id) } } } + Divider();Button("New Singer + Song…") { model.beginSinger(song:selected) } + }.disabled(selected == nil) + Button("Add to Tonight") { if let selected { model.addToTonight(selected) } }.disabled(selected == nil || model.currentVenue == nil || model.selectedSinger == nil) + } + PlaybackSettings(player:model.player,model:model,selectedSong:selected) + SeekBar(player:model.player) + }.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity) + .background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + @State private var playlistName = "" + @State private var selectedPlaylist: Int64? + private var queuePane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { Text("Queue · \(model.currentVenueName)").font(.headline); Spacer(); Toggle("Auto play next",isOn:$model.autoAdvance).toggleStyle(.checkbox); Button("Play Next") { model.playQueueNext() }.disabled(model.queue.isEmpty) } + ScrollView { + VStack(spacing:0) { + ForEach(Array(model.queue.enumerated()),id:\.element.id) { index,row in + HStack { + Text("\(index+1)").foregroundStyle(.secondary).frame(width:24) + Text(row.singer).frame(width:140,alignment:.leading) + Text(row.title).lineLimit(1); Spacer() + Button { model.moveQueue(row.id,by:-1) } label: { Image(systemName:"arrow.up") }.disabled(index == 0) + Button { model.moveQueue(row.id,by:1) } label: { Image(systemName:"arrow.down") }.disabled(index == model.queue.count-1) + Button("Remove") { model.removeQueue(row.id) } + }.padding(.vertical,8) + } + if model.queue.isEmpty { Text("Choose a venue and singer, select a library song, then click Queue Song.").foregroundStyle(.secondary).padding(.top,30) } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + @State private var historyAllVenues = false + private var displayedHistory: [Performance] { + if historyAllVenues { return (try? model.library?.history(venue:nil,singer:model.selectedSinger)) ?? [] } + return model.history.filter { model.selectedSinger == nil || $0.singer == model.singers.first(where: { $0.id == model.selectedSinger })?.name } + } + private var historyPane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { Text("History · \(historyAllVenues || model.currentVenue == nil ? "All venues" : model.currentVenueName)").font(.headline); Spacer(); Toggle("All venues",isOn:$historyAllVenues).toggleStyle(.checkbox) } + ScrollView { + LazyVStack(alignment:.leading,spacing:12) { + ForEach(displayedHistory) { row in + HStack { Text(row.date).font(.system(.caption,design:.monospaced)); Text(row.singer).frame(width:130,alignment:.leading); Text(row.song); Spacer(); Text(row.venue).foregroundStyle(.secondary) } + } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + private var playlistPane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { + Picker("Playlist",selection:$selectedPlaylist) { Text("Choose…").tag(nil as Int64?); ForEach(model.playlists) { Text($0.name).tag(Optional($0.id)) } } + TextField("New playlist name",text:$playlistName).textFieldStyle(.roundedBorder) + Button("Create") { model.createPlaylist(playlistName); selectedPlaylist = model.playlists.first { $0.name == playlistName.trimmingCharacters(in:.whitespacesAndNewlines) }?.id; playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + if let selectedPlaylist { + Button("Rename") { model.rename("playlists",id:selectedPlaylist,name:playlistName); playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + Button("Delete",role:.destructive) { model.delete("playlists",id:selectedPlaylist); self.selectedPlaylist = nil } } + } + ScrollView { + if let selectedPlaylist { + let ids = (try? model.library?.playlistSongIDs(selectedPlaylist)) ?? [] + VStack(alignment:.leading,spacing:10) { + ForEach(ids,id:\.self) { id in + if let song = model.songs.first(where: { $0.id == id }) { + HStack { + Text(song.title); Spacer() + Button("Play") { model.perform { try model.play(song) } } + Button("Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Button("Remove") { model.perform { try model.library?.removeFromPlaylist(selectedPlaylist,song:id); try model.refresh() } } + } + } + } + } + } + Text("Add songs using the library row’s context menu.").foregroundStyle(.secondary).padding(.top,20) + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + private var footer: some View { + HStack { + Button { importing = true } label: { Image(systemName: "plus").font(.system(size: 20, weight: .bold)).frame(width: 32, height: 24) } + .help("Import audio, MP3+G or ZIP").accessibilityLabel("Import songs") + RepeatButton(player: model.player).padding(.leading, 12) + Button(model.readingDurations ? "Reading…" : "Read Durations") { model.scanDurations() }.controlSize(.small).disabled(model.readingDurations).help("Read durations for songs imported by older versions") + Button("Edit Metadata…") { editingSong = selectedSong }.controlSize(.small).disabled(selectedSong == nil) + Spacer() + Text("\(visibleSongs.count) \(visibleSongs.count == 1 ? "Song" : "Songs")").font(.system(size: 14, weight: .medium)) + Spacer() + Button { openWindow(id: "lyrics") } label: { Image(systemName: "rectangle.on.rectangle").frame(width: 32, height: 24) } + .help("Open video window").accessibilityLabel("Open video window") + }.buttonStyle(SilverSquareStyle()) + } + private func playSelected() { + guard let song = selectedSong else { return } + model.perform { try model.play(song) } + } + private func playNext() { + guard !visibleSongs.isEmpty else { return } + let index = visibleSongs.firstIndex { $0.id == selected } ?? -1 + selected = visibleSongs[(index + 1) % visibleSongs.count].id; playSelected() + } + private func addEntity() { + model.perform { + let kind: EntityKind = source == "Singers" ? .singer : .venue + let id = try model.createEntity(kind, name: name) + if kind == .singer { model.selectedSinger = id } else { model.chooseVenue(id) } + name = "" + } + } +} +private struct TonightSongDropTarget: ViewModifier { + @ObservedObject var model: AppModel + let singer: Int64 + let venue: Int64? + let before: Int64? + let enabled: Bool + @State private var targeted = false + func body(content: Content) -> some View { + content + .background(targeted && enabled ? Color.cyan.opacity(0.14) : .clear) + .overlay(alignment:.top) { if targeted && enabled { Rectangle().fill(.cyan).frame(height:2) } } + .onDrop(of:enabled && venue != nil ? [SongDragReference.type] : [],isTargeted:$targeted) { providers in + guard enabled else { return false } + return model.acceptSongDrop(providers,singer:singer,venue:venue,before:before) + } + } +} +/// Drawn with literal colours, so native menu/button tint cannot turn favourite stars white. +private struct GoldStar: View { + var filled: Bool + var size: CGFloat = 14 + var body: some View { + Canvas { context,bounds in + let centre = CGPoint(x:bounds.width/2,y:bounds.height/2),outer = min(bounds.width,bounds.height)*0.46 + var star = Path() + for point in 0..<10 { + let angle = Double(point)*Double.pi/5-Double.pi/2,radius = point % 2 == 0 ? outer : outer*0.43 + let p = CGPoint(x:centre.x+CGFloat(cos(angle))*radius,y:centre.y+CGFloat(sin(angle))*radius) + if point == 0 { star.move(to:p) } else { star.addLine(to:p) } + } + star.closeSubpath() + let gold = Color(red:1,green:0.73,blue:0.08) + if filled { context.fill(star,with:.color(gold)) } + else { context.stroke(star,with:.color(gold.opacity(0.7)),lineWidth:1) } + }.frame(width:size,height:size).accessibilityHidden(true) + } +} +private struct GoldRating: View { + @Binding var rating: Int + var body: some View { + HStack(spacing:2) { ForEach(1..<6) { value in Button { rating = rating == value ? 0 : value } label: { GoldStar(filled:value <= rating) }.buttonStyle(.plain).help("\(value) stars; click the selected rating to clear").accessibilityLabel("Set \(value) stars") } } + } +} +private struct SongMetadataSheet: View { + @ObservedObject var model: AppModel + let song: Song + @Environment(\.dismiss) private var dismiss + @State private var title = "" + @State private var artist = "" + @State private var album = "" + @State private var label = "" + @State private var rating = 0 + @State private var error: String? + var body: some View { + VStack(alignment:.leading,spacing:14) { + Text("Edit Song Metadata").font(.title2.bold()) + Grid(alignment:.leading,horizontalSpacing:12,verticalSpacing:10) { + GridRow { Text("Title");TextField("Title",text:$title) } + GridRow { Text("Artist");TextField("Artist",text:$artist) } + GridRow { Text("Album");TextField("Album",text:$album) } + GridRow { Text("Label");TextField("Label / publisher",text:$label) } + GridRow { Text("Rating");GoldRating(rating:$rating) } + }.textFieldStyle(.roundedBorder) + Text("Saved to your library. Embedded audio-file tags are not changed, and re-importing will preserve your library edits.").font(.caption).foregroundStyle(.secondary) + Text(URL(fileURLWithPath:song.audio).lastPathComponent).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle).help(song.audio) + if let error { Text(error).foregroundStyle(.red).font(.callout) } + HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction);Button("Save") { save() }.keyboardShortcut(.defaultAction).disabled(title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) } + }.padding(24).frame(width:500).background(Classic.frame).preferredColorScheme(.dark) + .onAppear { title = song.title;artist = song.artist;album = song.album;label = song.label;rating = song.rating } + } + private func save() { do { try model.updateMetadata(song,title:title,artist:artist,album:album,label:label,rating:rating);dismiss() } catch { self.error = error.localizedDescription } } +} +private struct MusicStoresPane: View { + @ObservedObject var model: AppModel + @Binding var selected: Int64? + @Environment(\.openURL) private var openURL + @State private var editor = false + @State private var editing: MusicStore? + @State private var search = "" + @State private var deleting: Int64? + var body: some View { + VStack(alignment:.leading,spacing:14) { + HStack { Text("Music Stores").font(.headline);Spacer();Button("Add Store…") { editing = nil;editor = true } } + Text("Define websites and optional catalogue-search URLs. Purchases and subscriptions are handled by the store in your browser.").font(.callout).foregroundStyle(.secondary) + HStack { TextField("Search store catalogue",text:$search).textFieldStyle(.roundedBorder);Text("{query} in a store’s search URL is replaced with this text.").font(.caption).foregroundStyle(.secondary) } + ScrollView { + VStack(alignment:.leading,spacing:12) { + ForEach(model.musicStores) { store in + VStack(alignment:.leading,spacing:8) { + HStack { Image(systemName:"bag.fill").foregroundStyle(.green);Text(store.name).font(.headline);Spacer() + Button("Open Store") { open(store,query:"") } + Button("Search") { open(store,query:search) }.disabled(search.isEmpty || store.searchTemplate.isEmpty) + Button("Edit") { editing = store;editor = true } + Button("Delete",role:.destructive) { deleting = store.id } + } + Text(store.url).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + }.padding(12).background(selected == store.id ? Color.blue.opacity(0.15) : Color.white.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = store.id } + } + if model.musicStores.isEmpty { Text("No stores configured. Add a store name and website address to get started.").foregroundStyle(.secondary).padding(.top,20) } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + .sheet(isPresented:$editor) { MusicStoreEditor(model:model,store:editing) } + .confirmationDialog("Delete this store definition?",isPresented:Binding(get:{ deleting != nil },set:{ if !$0 { deleting = nil } })) { + Button("Delete",role:.destructive) { if let deleting { model.deleteStore(deleting) };deleting = nil } + } + } + private func open(_ store: MusicStore,query: String) { + do { openURL(try store.destination(query:query)) } catch { model.error = error.localizedDescription } + } +} +private struct MusicStoreEditor: View { + @ObservedObject var model: AppModel + let store: MusicStore? + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var url = "" + @State private var search = "" + @State private var error: String? + var body: some View { + VStack(alignment:.leading,spacing:14) { + Text(store == nil ? "Add Music Store" : "Edit Music Store").font(.title2.bold()) + TextField("Store name",text:$name).textFieldStyle(.roundedBorder) + TextField("Website URL — https://example.com",text:$url).textFieldStyle(.roundedBorder) + TextField("Optional search URL — https://example.com/search?q={query}",text:$search).textFieldStyle(.roundedBorder) + Text("Use {query} where the search words belong. Only http:// and https:// websites are accepted.").font(.caption).foregroundStyle(.secondary) + if let error { Text(error).foregroundStyle(.red).font(.callout) } + HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction);Button("Save") { save() }.keyboardShortcut(.defaultAction).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty || url.isEmpty) } + }.padding(24).frame(width:500).background(Classic.frame).preferredColorScheme(.dark) + .onAppear { name = store?.name ?? "";url = store?.url ?? "";search = store?.searchTemplate ?? "" } + } + private func save() { do { try model.saveStore(.init(id:store?.id ?? 0,name:name,url:url,searchTemplate:search));dismiss() } catch { self.error = error.localizedDescription } } +} +private struct SingerSongsSheet: View { + @ObservedObject var model: AppModel + let singer: PersonPlace + var tonight = false + @Environment(\.dismiss) private var dismiss + @State private var search = "" + @State private var importing = false + var body: some View { + VStack(alignment:.leading,spacing:14) { + Text(tonight ? "Tonight for \(singer.name)" : "Songs for \(singer.name)").font(.title2.bold()) + Text(tonight ? "Tick songs for this venue’s Tonight list. They stay here after playback and when switching singers." : "Tick songs to add them to this singer’s repertoire.").font(.callout).foregroundStyle(.secondary) + TextField("Search library",text:$search).textFieldStyle(.roundedBorder) + ScrollView { + LazyVStack(alignment:.leading,spacing:12) { + ForEach(model.songs.filter { search.isEmpty || $0.title.localizedCaseInsensitiveContains(search) }) { song in + HStack { + Toggle(song.title,isOn:Binding(get:{ tonight ? model.tonightSongIDs[singer.id]?.contains(song.id) == true : model.singerSongIDs[singer.id]?.contains(song.id) == true },set:{ included in + if tonight { if included { model.addToTonight(song.id,singer:singer.id) } else { model.removeFromTonight(song.id,singer:singer.id) } } + else { model.assign(song.id,to:singer.id,included:included) } + })).toggleStyle(.checkbox) + Spacer() + Button { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } label: { GoldStar(filled:model.favourites[singer.id]?.contains(song.id) == true,size:15) }.buttonStyle(.plain).help("Toggle favourite") + } + } + if model.songs.isEmpty { Text("Your library is empty. Import audio or MP3+G below.").foregroundStyle(.secondary) } + }.frame(maxWidth:.infinity,alignment:.leading) + }.frame(height:270) + HStack { Button("Import Songs…") { importing = true };Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) } + }.padding(24).frame(width:520).background(Classic.frame).preferredColorScheme(.dark) + .fileImporter(isPresented:$importing,allowedContentTypes:[.audio,.zip,UTType(filenameExtension:"cdg") ?? .data],allowsMultipleSelection:true) { result in + switch result { case .success(let urls):model.importFiles(urls);case .failure(let error):model.error = error.localizedDescription } + } + } +} +private struct AddEntitySheet: View { + @ObservedObject var model: AppModel + let kind: EntityKind + let saved: (Int64) -> Void + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var error: String? + @State private var venue: Int64? + @State private var songs = Set() + @State private var search = "" + @FocusState private var focused: Bool + var body: some View { + VStack(alignment:.leading,spacing:16) { + Text(kind == .singer ? "New Singer + Songs" : "New Venue").font(.title2.bold()) + TextField("\(kind.title) name",text:$name).textFieldStyle(.roundedBorder).focused($focused).onSubmit(save) + if kind == .singer { + Picker("Venue",selection:$venue) { Text("Choose a venue…").tag(nil as Int64?);ForEach(model.venues) { Text($0.name).tag(Optional($0.id)) } } + Text(venue == nil ? "Create a venue first, then add a singer to it." : "This singer will join the selected venue and become active.").font(.caption).foregroundStyle(.secondary) + Text("Add songs now (optional)").font(.headline) + TextField("Search library",text:$search).textFieldStyle(.roundedBorder) + ScrollView { + LazyVStack(alignment:.leading,spacing:10) { + ForEach(model.songs.filter { search.isEmpty || $0.title.localizedCaseInsensitiveContains(search) }) { song in + Toggle(song.title,isOn:Binding(get:{ songs.contains(song.id) },set:{ if $0 { songs.insert(song.id) } else { songs.remove(song.id) } })).toggleStyle(.checkbox) + } + if model.songs.isEmpty { Text("No library songs yet. You can add songs from the singer’s + button after importing.").font(.callout).foregroundStyle(.secondary) } + }.frame(maxWidth:.infinity,alignment:.leading) + }.frame(height:190) + } + if let error { Text(error).foregroundStyle(.red).font(.callout) } + HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Button(kind == .singer ? "Add Singer & Songs" : "Add Venue",action:save).keyboardShortcut(.defaultAction) + .disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty || (kind == .singer && venue == nil)) + } + }.padding(24).frame(width:kind == .singer ? 500 : 360).background(Classic.frame).preferredColorScheme(.dark) + .onAppear { focused = true;venue = model.currentVenue ?? model.venues.first?.id;songs = model.newSingerSongs } + } + private func save() { + do { + let id: Int64 + if kind == .singer { guard let venue else { throw LibraryError.message("Choose a venue") };id = try model.createSinger(name:name,venue:venue,songs:songs) } + else { id = try model.createEntity(kind,name:name) } + saved(id);dismiss() + } catch { self.error = error.localizedDescription } + } +} +private struct TransportView: View { + @ObservedObject var player: Player + @Binding var search: String + let hasSelection: Bool + let play: () -> Void + let next: () -> Void + let display: () -> Void + var body: some View { + HStack(alignment: .top, spacing: 28) { + VStack(spacing: 15) { + HStack(spacing: 7) { + SilverButton(symbol: "backward.fill", label: "Back 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time - 10) } + SilverButton(symbol: player.playing ? "pause.fill" : "play.fill", label: player.playing ? "Pause" : "Play", size: 49, enabled: hasSelection || player.duration > 0) { + if player.duration > 0 { player.toggle() } else { play() } + } + SilverButton(symbol: "stop", label: "Stop", size: 46, enabled: player.duration > 0) { player.stop() } + SilverButton(symbol: "forward.fill", label: "Forward 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time + 10) } + SilverButton(symbol: "forward.end.fill", label: "Play next song", size: 26, enabled: hasSelection, action: next) + } + HStack(spacing: 9) { + Image(systemName: "speaker.fill").font(.system(size: 11)) + Slider(value: $player.volume, in: 0...1).controlSize(.small).tint(.gray).accessibilityLabel("Volume") + Image(systemName: "speaker.wave.3.fill").font(.system(size: 12)) + }.frame(width: 180) + }.frame(width: 220) + NowPlayingWaveform(player: player).padding(.top, 4) + HStack(spacing: 12) { + TextField("Search", text: $search).textFieldStyle(.plain).font(.system(size: 16)) + .padding(.horizontal, 8).frame(width: 145, height: 28).background(Color(white: 0.07), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).stroke(.white.opacity(0.1))) + SilverButton(symbol: "eye", label: "Open video window", size: 48, action: display) + }.padding(.top, 15) + }.foregroundStyle(.white).frame(height: 130) + } +} +private struct NowPlayingWaveform: View { + @ObservedObject var player: Player + @State private var mixer = false + @State private var zoom: Double = 1 + @State private var hoverX: CGFloat? + @State private var scrubStart: Double? + @State private var resumeAfterScrub = false + @State private var scrubSong: Int64? + private var span: Double { max(0.001,player.duration/zoom) } + private var windowStart: Double { scrubStart ?? min(max(0,player.time-span/2),max(0,player.duration-span)) } + private var progress: Double { player.duration > 0 ? min(1,max(0,(player.time-windowStart)/span)) : 0 } + private func clock(_ value: Double) -> String { let n = max(0,Int(value));return String(format:"%d:%02d",n/60,n%60) } + private func preciseClock(_ value: Double) -> String { String(format:"%d:%05.2f",max(0,Int(value))/60,max(0,value).truncatingRemainder(dividingBy:60)) } + private func bandColor(_ index: Int) -> Color { + guard player.waveformBands.indices.contains(index) else { return .cyan } + let band = player.waveformBands[index] + let largest = max(0.000001,max(band.low,max(band.mid,band.high))) + let low = Double(band.low/largest),mid = Double(band.mid/largest),high = Double(band.high/largest) + return Color(red:min(1,pow(low,1.2)+mid*0.12+high*0.6),green:min(1,low*0.08+pow(mid,1.5)*0.97+high*0.1),blue:min(1,low*0.07+mid*0.22+high)) + } + var body: some View { + VStack(alignment:.leading,spacing:5) { + Text(player.duration > 0 ? player.title : "Ready to play").font(.system(size:13,weight:.semibold)).foregroundStyle(Color(white:0.94)) + .lineLimit(1).truncationMode(.middle).frame(maxWidth:.infinity,alignment:.leading).help(player.title) + GeometryReader { geometry in + Canvas { context,size in + let top: CGFloat = 10,plotHeight = size.height-top,mid = top+plotHeight/2 + var centre = Path();centre.move(to:CGPoint(x:0,y:mid));centre.addLine(to:CGPoint(x:size.width,y:mid)) + context.stroke(centre,with:.color(.white.opacity(0.13)),lineWidth:0.5) + if player.duration > 0 { + let step = [0.1,0.25,0.5,1.0,2,5,10,15,30,60,120,300,600,1800,3600].first { $0 >= span/5 } ?? 3600 + let first = floor(windowStart/(step/4))*(step/4) + for t in stride(from:first,through:min(player.duration,windowStart+span),by:step/4) { + let x = size.width*(t-windowStart)/span + if x < 0 { continue } + let major = abs(t/step-(t/step).rounded()) < 0.001 + var tick = Path();tick.move(to:CGPoint(x:x,y:top));tick.addLine(to:CGPoint(x:x,y:size.height)) + context.stroke(tick,with:.color(major ? .yellow.opacity(0.65) : .white.opacity(0.16)),lineWidth:major ? 0.8 : 0.5) + if major && x < size.width-25 { + context.draw(Text(span < 10 ? preciseClock(t) : clock(t)).font(.system(size:8,design:.monospaced)).foregroundColor(.gray),at:CGPoint(x:x+3,y:3),anchor:.leading) + } + } + } + if !player.waveform.isEmpty { + let count = max(1,Int(size.width/2)),step = size.width/CGFloat(count) + for bar in 0.. 0,geometry.size.width > 0 else { return } + if scrubStart == nil { + scrubStart = windowStart;scrubSong = player.currentSongID;resumeAfterScrub = player.playing + if player.playing { player.toggle() } + } + guard scrubSong == player.currentSongID else { return } + hoverX = value.location.x + let target = windowStart+Double(min(1,max(0,value.location.x/geometry.size.width)))*span + if abs(target-player.time) > 0.02 { player.seek(target) } + }.onEnded { value in + if scrubStart == nil { + if player.duration > 0 { player.seek(windowStart+Double(min(1,max(0,value.location.x/max(1,geometry.size.width))))*span) } + return + } + let target = windowStart+Double(min(1,max(0,value.location.x/max(1,geometry.size.width))))*span + if scrubSong == player.currentSongID { + player.seek(target) + if resumeAfterScrub && !player.playing && target < player.duration-0.01 { player.toggle() } + } + scrubStart = nil;resumeAfterScrub = false;scrubSong = nil + }).help("Hover for exact time. Drag to scrub; playback resumes on release. Zoom for fine positioning.") + .accessibilityLabel("Audio waveform").accessibilityValue("\(clock(player.time)) of \(clock(player.duration))") + .accessibilityAdjustableAction { direction in if direction == .increment { player.seek(player.time+5) } else if direction == .decrement { player.seek(player.time-5) } } + }.frame(height:60) + HStack(spacing:5) { + Circle().fill(player.playing ? Color.green : Color.orange).frame(width:4,height:4) + Text(player.duration == 0 ? "NO TRACK LOADED" : player.playing ? "PLAYING" : "PAUSED").tracking(1) + if player.waveformUnavailable { Text("· Waveform unavailable").foregroundStyle(.secondary) } + Spacer(minLength:0) + Button { zoom = max(1,zoom/2) } label: { Image(systemName:"minus.magnifyingglass") }.buttonStyle(.plain).disabled(zoom <= 1).help("Zoom out") + Button { zoom = min(32,zoom*2) } label: { Image(systemName:"plus.magnifyingglass") }.buttonStyle(.plain).disabled(zoom >= 32).help("Zoom in for precise scrubbing") + Menu("\(Int(zoom))×") { ForEach([1.0,2,4,8,16,32],id:\.self) { value in Button("\(Int(value))× zoom") { zoom = value } } }.menuStyle(.borderlessButton).frame(width:34).help("Waveform zoom around the playhead") + Menu { Button("Re-analyse Waveform") { player.reanalyseWaveform() }.disabled(player.duration == 0) + Text(player.waveformLoading ? "Analysing…" : player.waveformFromCache ? "Loaded saved waveform" : player.waveformSaved ? "Analysis saved" : "No saved analysis") + } label: { Image(systemName:"waveform") }.menuStyle(.borderlessButton).frame(width:20).help("Waveform cache and re-analysis") + Button("EQ / TONE") { mixer.toggle() }.buttonStyle(.plain).foregroundStyle(.white.opacity(0.85)) + .padding(.horizontal,6).padding(.vertical,2).background(.white.opacity(0.08),in:RoundedRectangle(cornerRadius:3)) + .popover(isPresented:$mixer) { ToneMixerView(player:player) } + Text("\(clock(player.time)) / \(clock(player.duration))").monospacedDigit().padding(.leading,5) + }.font(.system(size:9,weight:.medium)).foregroundStyle(Color(white:0.6)) + }.padding(.horizontal,12).padding(.vertical,9).frame(maxWidth:.infinity).frame(height:120) + .background(Color(red:0.025,green:0.028,blue:0.035)).overlay(Rectangle().strokeBorder(Color(white:0.24),lineWidth:1)) + } +} +private struct ToneKnob: View { + let title: String + @Binding var value: Double + let range: ClosedRange + var filter = false + @State private var initial: Double? + private var fraction: Double { min(1,max(0,(value-range.lowerBound)/(range.upperBound-range.lowerBound))) } + private var display: String { + if filter { return abs(value) < 0.01 ? "OFF" : value < 0 ? "LOW PASS" : "HIGH PASS" } + return String(format:"%+.1f dB",value) + } + var body: some View { + VStack(spacing:4) { + ZStack { + Circle().trim(from:0,to:0.75).stroke(Color(white:0.21),style:StrokeStyle(lineWidth:4,lineCap:.round)).rotationEffect(.degrees(135)) + Circle().trim(from:0,to:0.75*fraction).stroke(Color(white:0.48),style:StrokeStyle(lineWidth:3,lineCap:.round)).rotationEffect(.degrees(135)) + Circle().fill(LinearGradient(colors:[Color(white:0.19),Color(white:0.09)],startPoint:.top,endPoint:.bottom)).padding(6).overlay(Circle().stroke(.black.opacity(0.65),lineWidth:1.5).padding(6)) + Capsule().fill(.white).frame(width:2,height:12).offset(y:-13).rotationEffect(.degrees(-135+270*fraction)) + }.frame(width:50,height:50).contentShape(Circle()) + .gesture(DragGesture(minimumDistance:0).onChanged { gesture in + if initial == nil { initial = value } + value = min(range.upperBound,max(range.lowerBound,(initial ?? value)-Double(gesture.translation.height)/130*(range.upperBound-range.lowerBound))) + }.onEnded { _ in initial = nil }) + .simultaneousGesture(TapGesture(count:2).onEnded { value = 0 }) + .accessibilityElement().accessibilityLabel(title).accessibilityValue(display) + .accessibilityAdjustableAction { direction in + let change = (range.upperBound-range.lowerBound)/24 + value = min(range.upperBound,max(range.lowerBound,value+(direction == .increment ? change : -change))) + }.help("Drag up/down to adjust. Double-click to reset.") + Text(title).font(.system(size:10,weight:.bold)).foregroundStyle(Color(white:0.6)) + Text(display).font(.system(size:8,design:.monospaced)).foregroundStyle(Color(white:0.45)) + } + } +} +private struct StereoMeter: View { + @ObservedObject var player: Player + private func normal(_ level: Float) -> Double { max(0,min(1,(20*log10(Double(max(level,0.000001)))+60)/60)) } + var body: some View { + HStack(spacing:3) { + ForEach(0..<2) { channel in + VStack(spacing:2) { + ForEach((0..<24).reversed(),id:\.self) { segment in + let on = Double(segment)/24 < normal(channel == 0 ? player.leftLevel : player.rightLevel) + Rectangle().fill(on ? (segment > 21 ? Color.red : segment > 17 ? Color.yellow : Color.green) : Color.black.opacity(0.5)) + } + } + } + }.frame(width:23,height:125).accessibilityLabel("Stereo output levels") + } +} +private struct MixerFader: View { + @Binding var value: Double + var body: some View { + GeometryReader { geometry in + let travel = geometry.size.height-22 + ZStack(alignment:.top) { + VStack { ForEach(0..<7) { _ in Rectangle().fill(.white.opacity(0.09)).frame(height:1);Spacer(minLength:0) } }.padding(.vertical,11) + Capsule().fill(.black.opacity(0.8)).frame(width:5).padding(.vertical,8) + RoundedRectangle(cornerRadius:5).fill(LinearGradient(colors:[Color(white:0.45),Color(white:0.23)],startPoint:.top,endPoint:.bottom)) + .frame(width:45,height:20).overlay(Rectangle().fill(.white.opacity(0.15)).frame(width:18,height:1)) + .offset(y:(1-value)*travel) + }.frame(maxWidth:.infinity).contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance:0).onChanged { value = min(1,max(0,1-Double(($0.location.y-11)/travel))) }) + }.frame(width:56,height:135).accessibilityLabel("Master volume").accessibilityValue("\(Int(value*100)) percent") + .accessibilityAdjustableAction { direction in value = min(1,max(0,value+(direction == .increment ? 0.05 : -0.05))) } + } +} +private struct ToneMixerView: View { + @ObservedObject var player: Player + var body: some View { + VStack(spacing:14) { + HStack { Text("TONE / MIXER").font(.system(size:12,weight:.bold));Spacer();Toggle("EQ",isOn:$player.toneEnabled).toggleStyle(.switch).controlSize(.mini) } + HStack(alignment:.top,spacing:28) { + VStack(spacing:8) { + ToneKnob(title:"HIGH",value:$player.highGain,range:-12...12) + ToneKnob(title:"MID",value:$player.midGain,range:-12...12) + ToneKnob(title:"LOW",value:$player.lowGain,range:-12...12) + } + VStack(spacing:15) { + ToneKnob(title:"FILTER",value:$player.filter,range:-1...1,filter:true) + HStack(spacing:20) { StereoMeter(player:player);MixerFader(value:$player.volume) } + Text(player.volume <= 0 ? "−∞ dB" : String(format:"%+.1f dB",20*log10(player.volume))).font(.system(size:10,design:.monospaced)).foregroundStyle(.secondary) + } + }.padding(.horizontal,15) + HStack { Text("Drag knobs · Double-click to reset").font(.system(size:9)).foregroundStyle(.secondary);Spacer();Button("Reset EQ") { player.resetTone() }.controlSize(.small) } + }.padding(18).frame(width:270).background(Color(white:0.105)).preferredColorScheme(.dark) + } +} +private struct PlaybackSettings: View { + @ObservedObject var player: Player + @ObservedObject var model: AppModel + let selectedSong: Int64? + @State private var personalRating = 0 + var body: some View { + HStack(spacing:10) { + Text("Key") + Picker("Key",selection:$player.pitch) { ForEach(-12..<13) { Text(String(format:"%+d",$0)).tag(Double($0)) } }.labelsHidden().frame(width:70) + Text("Tempo") + Slider(value:$player.tempo,in:0.5...1.5,step:0.05).frame(width:85) + Text(String(format:"%.0f%%",player.tempo*100)).monospacedDigit().frame(width:40) + Text("Rating") + GoldRating(rating:$personalRating).frame(width:88).help("Singer-specific song rating") + Button("Save for Singer") { + if let song = player.currentSongID ?? selectedSong { model.savePreference(song:song,value:.init(pitch:player.pitch,tempo:player.tempo,rating:personalRating)) } + }.disabled(model.selectedSinger == nil || (player.currentSongID == nil && selectedSong == nil)) + Menu { + Button("Reset key / tempo") { player.pitch = 0; player.tempo = 1 } + ForEach([-1.0,-0.5,0,0.5,1.0],id:\.self) { offset in Button(String(format:"Graphics offset %+.1fs",offset)) { player.graphicsOffset = offset } } + } label: { Image(systemName:"slider.horizontal.3") }.menuStyle(.borderlessButton).frame(width:22).help("Playback settings") + }.font(.system(size:11)) + .onChange(of:model.selectedSinger) { _ in loadRating() } + .onChange(of:player.currentSongID) { _ in loadRating() } + .onChange(of:selectedSong) { _ in loadRating() } + } + private func loadRating() { if let song = player.currentSongID ?? selectedSong { personalRating = model.preference(song).rating } else { personalRating = 0 } } +} +private struct SeekBar: View { + @ObservedObject var player: Player + private func clock(_ seconds: Double) -> String { let n = Int(seconds); return String(format: "%d:%02d", n / 60, n % 60) } + var body: some View { + HStack(spacing: 10) { + Text(clock(player.time)) + Slider(value: Binding(get: { player.time }, set: { player.seek($0) }), in: 0...max(1, player.duration)) + .controlSize(.mini).tint(.gray).disabled(player.duration == 0).accessibilityLabel("Playback position") + Text(clock(player.duration)) + }.font(.system(size: 10, design: .monospaced)).foregroundStyle(.white.opacity(0.65)) + } +} +private struct RepeatButton: View { + @ObservedObject var player: Player + var body: some View { + Button { player.repeating.toggle() } label: { + Image(systemName: "repeat").font(.system(size: 18, weight: .semibold)) + .foregroundStyle(player.repeating ? Color.blue : Color(white: 0.25)).frame(width: 32, height: 24) + }.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off") + } +} diff --git a/backups/before-kjmix-20261103-100337/KJamsClone/Library.swift b/backups/before-kjmix-20261103-100337/KJamsClone/Library.swift new file mode 100644 index 0000000..ce3d2e4 --- /dev/null +++ b/backups/before-kjmix-20261103-100337/KJamsClone/Library.swift @@ -0,0 +1,348 @@ +import Foundation +import SQLite3 + +struct Song: Identifiable, Hashable { + var id: Int64 + var title: String + var audio: String + var cdg: String? + var rating: Int + var plays: Int + var artist: String = "" + var album: String = "" + var label: String = "" + var duration: Double = 0 +} +struct MusicStore: Identifiable, Hashable { + var id: Int64 + var name: String + var url: String + var searchTemplate: String + func destination(query: String = "") throws -> URL { + let query = query.trimmingCharacters(in:.whitespacesAndNewlines) + let encoded = query.addingPercentEncoding(withAllowedCharacters:CharacterSet(charactersIn:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")) ?? "" + let value = query.isEmpty || searchTemplate.isEmpty ? url : searchTemplate.replacingOccurrences(of:"{query}",with:encoded) + guard let result = URL(string:value), ["http","https"].contains(result.scheme?.lowercased() ?? ""), result.host?.isEmpty == false, result.user == nil, result.password == nil else { throw LibraryError.message("Use a valid http:// or https:// store URL without credentials") } + return result + } +} +struct PersonPlace: Identifiable, Hashable { var id: Int64; var name: String } +struct SongPreference: Equatable { + var pitch: Double = 0 + var tempo: Double = 1 + var rating: Int = 0 +} +struct Performance: Identifiable { + var id: Int64 + var song: String + var singer: String + var venue: String + var date: String + var singerID: Int64? + var songID: Int64? +} +struct QueueEntry: Identifiable { + var id: Int64 + var songID: Int64 + var singerID: Int64? + var title: String + var singer: String +} +enum LibraryError: LocalizedError { + case message(String) + var errorDescription: String? { if case .message(let s) = self { return s }; return nil } +} +final class Library { + private var db: OpaquePointer? + init(url: URL) throws { + guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw LibraryError.message("Cannot open library") } + try execute("PRAGMA foreign_keys=ON") + try execute("CREATE TABLE IF NOT EXISTS songs(id INTEGER PRIMARY KEY,title TEXT NOT NULL,audio TEXT UNIQUE NOT NULL,cdg TEXT,rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),plays INTEGER NOT NULL DEFAULT 0)") + try execute("CREATE TABLE IF NOT EXISTS singers(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS venues(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS history(id INTEGER PRIMARY KEY,song INTEGER REFERENCES songs(id),singer INTEGER REFERENCES singers(id),venue INTEGER REFERENCES venues(id),played TEXT DEFAULT CURRENT_TIMESTAMP)") + // Additive migration: preserve older libraries and infer existing memberships from history. + for column in ["artist", "album", "label"] { + if try !columns("songs").contains(column) { try execute("ALTER TABLE songs ADD COLUMN \(column) TEXT NOT NULL DEFAULT ''") } + } + try execute("CREATE TABLE IF NOT EXISTS venue_singers(venue INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,PRIMARY KEY(venue,singer))") + if try schemaVersion() < 3 { try execute("INSERT OR IGNORE INTO venue_singers SELECT DISTINCT venue,singer FROM history WHERE venue IS NOT NULL AND singer IS NOT NULL") } + try execute("CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT)") + try execute("CREATE TABLE IF NOT EXISTS preferences(song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5),rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),PRIMARY KEY(song,singer))") + try execute("CREATE TABLE IF NOT EXISTS queue(id INTEGER PRIMARY KEY,venue INTEGER REFERENCES venues(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER REFERENCES singers(id) ON DELETE CASCADE,position INTEGER NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS playlists(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS playlist_songs(playlist INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(playlist,song))") + try execute("CREATE TABLE IF NOT EXISTS singer_songs(singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(singer,song))") + if try schemaVersion() < 4 { + try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,MIN(id) FROM history WHERE singer IS NOT NULL AND song IS NOT NULL GROUP BY singer,song") + try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,0 FROM preferences") + } + if try !columns("songs").contains("duration") { try execute("ALTER TABLE songs ADD COLUMN duration REAL NOT NULL DEFAULT 0") } + if try !columns("singer_songs").contains("favourite") { try execute("ALTER TABLE singer_songs ADD COLUMN favourite INTEGER NOT NULL DEFAULT 0 CHECK(favourite IN (0,1))") } + if try schemaVersion() < 5 { try execute("UPDATE singer_songs SET favourite=1 WHERE EXISTS(SELECT 1 FROM preferences WHERE preferences.singer=singer_songs.singer AND preferences.song=singer_songs.song AND preferences.rating>=4)") } + try execute("CREATE TABLE IF NOT EXISTS song_settings(song INTEGER PRIMARY KEY REFERENCES songs(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5))") + try execute("CREATE TABLE IF NOT EXISTS music_stores(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL,url TEXT NOT NULL,search_template TEXT NOT NULL DEFAULT '')") + if try !columns("songs").contains("metadata_edited") { try execute("ALTER TABLE songs ADD COLUMN metadata_edited INTEGER NOT NULL DEFAULT 0") } + try execute("CREATE TABLE IF NOT EXISTS singer_tonight(venue INTEGER NOT NULL,singer INTEGER NOT NULL,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(venue,singer,song),FOREIGN KEY(venue,singer) REFERENCES venue_singers(venue,singer) ON DELETE CASCADE)") + if try schemaVersion() < 7 { + try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) SELECT queue.venue,queue.singer,queue.song,MIN(queue.position) FROM queue JOIN venue_singers ON venue_singers.venue=queue.venue AND venue_singers.singer=queue.singer WHERE queue.singer IS NOT NULL GROUP BY queue.venue,queue.singer,queue.song") + } + try execute("PRAGMA user_version=7") + } + deinit { sqlite3_close(db) } + private func statement(_ sql: String, _ values: [String?]) throws -> OpaquePointer { + var s: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &s, nil) == SQLITE_OK, let s else { throw error() } + for (i,v) in values.enumerated() { + let code = v.map { sqlite3_bind_text(s, Int32(i + 1), $0, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) } ?? sqlite3_bind_null(s, Int32(i + 1)) + if code != SQLITE_OK { sqlite3_finalize(s); throw error() } + } + return s + } + private func error() -> LibraryError { .message(String(cString: sqlite3_errmsg(db))) } + func execute(_ sql: String, _ values: [String?] = []) throws { + let s = try statement(sql, values); defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_DONE else { throw error() } + } + func songs() throws -> [Song] { + let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label,duration FROM songs ORDER BY title COLLATE NOCASE", []); defer { sqlite3_finalize(s) } + var result = [Song]() + while true { + let code = sqlite3_step(s); if code == SQLITE_DONE { break }; guard code == SQLITE_ROW else { throw error() } + result.append(Song(id: sqlite3_column_int64(s,0), title: String(cString: sqlite3_column_text(s,1)), audio: String(cString: sqlite3_column_text(s,2)), cdg: sqlite3_column_text(s,3).map { String(cString:$0) }, rating: Int(sqlite3_column_int(s,4)), plays: Int(sqlite3_column_int(s,5)), artist: text(s,6), album: text(s,7), label: text(s,8),duration:sqlite3_column_double(s,9))) + } + return result + } + func add(title: String, audio: String, cdg: String?, artist: String = "", album: String = "", label: String = "", duration: Double = 0) throws { + try execute("INSERT INTO songs(title,audio,cdg,artist,album,label,duration) VALUES(?,?,?,?,?,?,?) ON CONFLICT(audio) DO UPDATE SET cdg=COALESCE(excluded.cdg,songs.cdg),artist=CASE WHEN songs.metadata_edited=0 AND excluded.artist!='' THEN excluded.artist ELSE songs.artist END,album=CASE WHEN songs.metadata_edited=0 AND excluded.album!='' THEN excluded.album ELSE songs.album END,label=CASE WHEN songs.metadata_edited=0 AND excluded.label!='' THEN excluded.label ELSE songs.label END,duration=CASE WHEN excluded.duration>0 THEN excluded.duration ELSE songs.duration END", [title,audio,cdg,artist,album,label,String(duration)]) + } + func rate(_ id: Int64, _ rating: Int) throws { try execute("UPDATE songs SET rating=? WHERE id=?", [String(rating),String(id)]) } + func entities(_ table: String) throws -> [PersonPlace] { + precondition(["singers","venues","playlists"].contains(table)) + let s = try statement("SELECT id,name FROM \(table) ORDER BY name", []); defer { sqlite3_finalize(s) } + var rows = [PersonPlace]() + while sqlite3_step(s) == SQLITE_ROW { rows.append(.init(id: sqlite3_column_int64(s,0), name: String(cString: sqlite3_column_text(s,1)))) } + return rows + } + func addEntity(_ table: String, name: String) throws { + precondition(["singers","venues","playlists"].contains(table)) + let name = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { throw LibraryError.message("Enter a name") } + try execute("INSERT OR IGNORE INTO \(table)(name) VALUES(?)", [name]) + } + func record(_ song: Int64, singer: Int64?, venue: Int64?) throws { + try execute("BEGIN IMMEDIATE") + do { + if let singer { try assignSong(song,singer:singer) } + try execute("INSERT INTO history(song,singer,venue) VALUES(?,?,?)", [String(song),singer.map(String.init),venue.map(String.init)]) + try execute("UPDATE songs SET plays=plays+1 WHERE id=?", [String(song)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK"); throw error } + } + + private func text(_ s: OpaquePointer, _ index: Int32) -> String { sqlite3_column_text(s,index).map { String(cString:$0) } ?? "" } + private func schemaVersion() throws -> Int { + let s = try statement("PRAGMA user_version",[]); defer { sqlite3_finalize(s) } + return sqlite3_step(s) == SQLITE_ROW ? Int(sqlite3_column_int(s,0)) : 0 + } + private func columns(_ table: String) throws -> Set { + let s = try statement("PRAGMA table_info(\(table))", []); defer { sqlite3_finalize(s) } + var result = Set(); while sqlite3_step(s) == SQLITE_ROW { result.insert(text(s,1)) }; return result + } + func members(of venue: Int64) throws -> [PersonPlace] { + let s = try statement("SELECT singers.id,singers.name FROM singers JOIN venue_singers ON singers.id=venue_singers.singer WHERE venue=? ORDER BY name", [String(venue)]); defer { sqlite3_finalize(s) } + var result = [PersonPlace](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1))) }; return result + } + func setMembership(singer: Int64, venue: Int64, included: Bool) throws { + try execute(included ? "INSERT OR IGNORE INTO venue_singers(venue,singer) VALUES(?,?)" : "DELETE FROM venue_singers WHERE venue=? AND singer=?", [String(venue),String(singer)]) + if !included { try execute("DELETE FROM queue WHERE venue=? AND singer=?",[String(venue),String(singer)]) } + } + func currentVenue() throws -> Int64? { + let s = try statement("SELECT venues.id FROM venues JOIN settings ON CAST(settings.value AS INTEGER)=venues.id WHERE settings.key='currentVenue'", []); defer { sqlite3_finalize(s) } + return sqlite3_step(s) == SQLITE_ROW ? sqlite3_column_int64(s,0) : nil + } + func selectVenue(_ id: Int64?) throws { + if let id { + guard try entities("venues").contains(where: { $0.id == id }) else { throw LibraryError.message("Venue no longer exists") } + } + try execute("INSERT INTO settings(key,value) VALUES('currentVenue',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",[id.map(String.init)]) + } + func rename(_ table: String, id: Int64, name: String) throws { + precondition(["singers","venues","playlists"].contains(table)) + let name = name.trimmingCharacters(in:.whitespacesAndNewlines) + guard !name.isEmpty else { throw LibraryError.message("Enter a name") } + try execute("UPDATE \(table) SET name=? WHERE id=?",[name,String(id)]) + } + func deleteEntity(_ table: String, id: Int64) throws { + precondition(["singers","venues","playlists"].contains(table)) + try execute("BEGIN IMMEDIATE") + do { + if table == "singers" { try execute("UPDATE history SET singer=NULL WHERE singer=?",[String(id)]) } + if table == "venues" { + try execute("UPDATE history SET venue=NULL WHERE venue=?",[String(id)]) + try execute("DELETE FROM settings WHERE key='currentVenue' AND value=?",[String(id)]) + } + try execute("DELETE FROM \(table) WHERE id=?",[String(id)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK"); throw error } + } + func preference(song: Int64, singer: Int64) throws -> SongPreference { + let s = try statement("SELECT pitch,tempo,rating FROM preferences WHERE song=? AND singer=?",[String(song),String(singer)]); defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_ROW else { return try defaultPreference(song:song) } + return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:Int(sqlite3_column_int(s,2))) + } + func savePreference(song: Int64, singer: Int64, value: SongPreference) throws { + try execute("INSERT INTO preferences(song,singer,pitch,tempo,rating) VALUES(?,?,?,?,?) ON CONFLICT(song,singer) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo,rating=excluded.rating",[String(song),String(singer),String(value.pitch),String(value.tempo),String(value.rating)]) + } + func history(venue: Int64?, singer: Int64? = nil) throws -> [Performance] { + let s = try statement("SELECT history.id,COALESCE(songs.title,'Deleted song'),COALESCE(singers.name,'—'),COALESCE(venues.name,'—'),played,history.singer,history.song FROM history LEFT JOIN songs ON songs.id=history.song LEFT JOIN singers ON singers.id=history.singer LEFT JOIN venues ON venues.id=history.venue WHERE (? IS NULL OR history.venue=?) AND (? IS NULL OR history.singer=?) ORDER BY history.id DESC",[venue.map(String.init),venue.map(String.init),singer.map(String.init),singer.map(String.init)]); defer { sqlite3_finalize(s) } + var result = [Performance](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),song:text(s,1),singer:text(s,2),venue:text(s,3),date:text(s,4),singerID:sqlite3_column_type(s,5) == SQLITE_NULL ? nil : sqlite3_column_int64(s,5),songID:sqlite3_column_type(s,6) == SQLITE_NULL ? nil : sqlite3_column_int64(s,6))) }; return result + } + func enqueue(song: Int64, singer: Int64?, venue: Int64?) throws { + guard let venue else { throw LibraryError.message("Select a current venue before adding to its queue") } + if let singer, try !members(of:venue).contains(where: { $0.id == singer }) { throw LibraryError.message("Add this singer to the current venue first") } + try execute("BEGIN IMMEDIATE") + do { + if let singer { + try assignSong(song,singer:singer) + try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_tonight WHERE venue=? AND singer=?))",[String(venue),String(singer),String(song),String(venue),String(singer)]) + } + try execute("INSERT INTO queue(venue,song,singer,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?))",[String(venue),String(song),singer.map(String.init),String(venue)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK");throw error } + } + func queue(venue: Int64?) throws -> [QueueEntry] { + guard let venue else { return [] } + let s = try statement("SELECT queue.id,queue.song,queue.singer,songs.title,COALESCE(singers.name,'No singer') FROM queue JOIN songs ON songs.id=queue.song LEFT JOIN singers ON singers.id=queue.singer WHERE queue.venue=? ORDER BY queue.position,queue.id",[String(venue)]); defer { sqlite3_finalize(s) } + var result = [QueueEntry](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),songID:sqlite3_column_int64(s,1),singerID:sqlite3_column_type(s,2) == SQLITE_NULL ? nil : sqlite3_column_int64(s,2),title:text(s,3),singer:text(s,4))) }; return result + } + func removeQueue(_ id: Int64) throws { try execute("DELETE FROM queue WHERE id=?",[String(id)]) } + func moveQueue(_ id: Int64, venue: Int64, by offset: Int) throws { + var rows = try queue(venue:venue) + guard let index = rows.firstIndex(where: { $0.id == id }), rows.indices.contains(index+offset) else { return } + rows.swapAt(index,index+offset) + try execute("BEGIN IMMEDIATE") + do { for (i,row) in rows.enumerated() { try execute("UPDATE queue SET position=? WHERE id=?",[String(i),String(row.id)]) }; try execute("COMMIT") } + catch { try? execute("ROLLBACK"); throw error } + } + func addToPlaylist(_ playlist: Int64, song: Int64) throws { + try execute("INSERT OR IGNORE INTO playlist_songs(playlist,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM playlist_songs WHERE playlist=?))",[String(playlist),String(song),String(playlist)]) + } + func playlistSongIDs(_ playlist: Int64) throws -> [Int64] { + let s = try statement("SELECT song FROM playlist_songs WHERE playlist=? ORDER BY position",[String(playlist)]); defer { sqlite3_finalize(s) } + var ids = [Int64](); while sqlite3_step(s) == SQLITE_ROW { ids.append(sqlite3_column_int64(s,0)) }; return ids + } + func removeFromPlaylist(_ playlist: Int64, song: Int64) throws { try execute("DELETE FROM playlist_songs WHERE playlist=? AND song=?",[String(playlist),String(song)]) } + func assignSong(_ song: Int64, singer: Int64) throws { + try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_songs WHERE singer=?))",[String(singer),String(song),String(singer)]) + } + func removeSingerSong(_ song: Int64, singer: Int64) throws { + try execute("DELETE FROM singer_songs WHERE singer=? AND song=?",[String(singer),String(song)]) + } + func singerSongIDs() throws -> [Int64:[Int64]] { + let s = try statement("SELECT singer,song FROM singer_songs ORDER BY singer,position,song",[]); defer { sqlite3_finalize(s) } + var result = [Int64:[Int64]]() + while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].append(sqlite3_column_int64(s,1)) } + return result + } + func defaultPreference(song: Int64) throws -> SongPreference { + let s = try statement("SELECT pitch,tempo FROM song_settings WHERE song=?",[String(song)]);defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_ROW else { return .init() } + return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:0) + } + func saveDefaultPreference(song: Int64, value: SongPreference) throws { + try execute("INSERT INTO song_settings(song,pitch,tempo) VALUES(?,?,?) ON CONFLICT(song) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo",[String(song),String(value.pitch),String(value.tempo)]) + } + func updateDuration(_ song: Int64, seconds: Double) throws { + guard seconds.isFinite && seconds >= 0 else { return } + try execute("UPDATE songs SET duration=? WHERE id=?",[String(seconds),String(song)]) + } + func setFavourite(song: Int64, singer: Int64, included: Bool) throws { + if included { try assignSong(song,singer:singer) } + try execute("UPDATE singer_songs SET favourite=? WHERE singer=? AND song=?",[included ? "1" : "0",String(singer),String(song)]) + } + func favouriteSongIDs() throws -> [Int64:Set] { + let s = try statement("SELECT singer,song FROM singer_songs WHERE favourite=1",[]);defer { sqlite3_finalize(s) } + var result = [Int64:Set]() + while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].insert(sqlite3_column_int64(s,1)) };return result + } + func stores() throws -> [MusicStore] { + let s = try statement("SELECT id,name,url,search_template FROM music_stores ORDER BY name",[]);defer { sqlite3_finalize(s) } + var result = [MusicStore]() + while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1),url:text(s,2),searchTemplate:text(s,3))) };return result + } + func saveStore(_ store: MusicStore) throws { + let name = store.name.trimmingCharacters(in:.whitespacesAndNewlines),url = store.url.trimmingCharacters(in:.whitespacesAndNewlines),search = store.searchTemplate.trimmingCharacters(in:.whitespacesAndNewlines) + guard !name.isEmpty else { throw LibraryError.message("Enter a store name") } + let cleaned = MusicStore(id:store.id,name:name,url:url,searchTemplate:search) + _ = try cleaned.destination() + if !search.isEmpty { + guard search.contains("{query}") else { throw LibraryError.message("Search URL must contain {query}") } + _ = try cleaned.destination(query:"test") + } + if store.id == 0 { try execute("INSERT INTO music_stores(name,url,search_template) VALUES(?,?,?)",[name,url,search]) } + else { try execute("UPDATE music_stores SET name=?,url=?,search_template=? WHERE id=?",[name,url,search,String(store.id)]) } + } + func deleteStore(_ id: Int64) throws { try execute("DELETE FROM music_stores WHERE id=?",[String(id)]) } + func updateMetadata(song: Int64,title: String,artist: String,album: String,label: String,rating: Int) throws { + let title = title.trimmingCharacters(in:.whitespacesAndNewlines) + guard !title.isEmpty else { throw LibraryError.message("Song title cannot be empty") } + try execute("UPDATE songs SET title=?,artist=?,album=?,label=?,rating=?,metadata_edited=1 WHERE id=?",[title,artist.trimmingCharacters(in:.whitespacesAndNewlines),album.trimmingCharacters(in:.whitespacesAndNewlines),label.trimmingCharacters(in:.whitespacesAndNewlines),String(rating),String(song)]) + guard sqlite3_changes(db) == 1 else { throw LibraryError.message("Song no longer exists") } + } + func tonightSongIDs(venue: Int64?) throws -> [Int64:[Int64]] { + guard let venue else { return [:] } + let s = try statement("SELECT singer,song FROM singer_tonight WHERE venue=? ORDER BY position,song",[String(venue)]);defer { sqlite3_finalize(s) } + var result = [Int64:[Int64]]() + while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].append(sqlite3_column_int64(s,1)) };return result + } + func addTonight(song: Int64,singer: Int64,venue: Int64) throws { + guard try members(of:venue).contains(where:{ $0.id == singer }) else { throw LibraryError.message("This singer is not a member of the current venue") } + try execute("BEGIN IMMEDIATE") + do { + try assignSong(song,singer:singer) + try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_tonight WHERE venue=? AND singer=?))",[String(venue),String(singer),String(song),String(venue),String(singer)]) + try execute("INSERT INTO queue(venue,song,singer,position) SELECT ?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?) WHERE NOT EXISTS(SELECT 1 FROM queue WHERE venue=? AND singer=? AND song=?)",[String(venue),String(song),String(singer),String(venue),String(venue),String(singer),String(song)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK");throw error } + } + func removeTonight(song: Int64,singer: Int64,venue: Int64) throws { + try execute("BEGIN IMMEDIATE") + do { + try execute("DELETE FROM singer_tonight WHERE venue=? AND singer=? AND song=?",[String(venue),String(singer),String(song)]) + try execute("DELETE FROM queue WHERE venue=? AND singer=? AND song=?",[String(venue),String(singer),String(song)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK");throw error } + } + /// Atomically adds/copies or reorders a request, retaining other singers' queue slots. + func placeTonight(song: Int64,singer: Int64,venue: Int64,before: Int64?,addIfNeeded: Bool) throws { + guard try members(of:venue).contains(where:{ $0.id == singer }) else { throw LibraryError.message("This singer is not in the target venue") } + try execute("BEGIN IMMEDIATE") + do { + var ids = try tonightSongIDs(venue:venue)[singer] ?? [] + if let before, !ids.contains(before) { throw LibraryError.message("The drop target changed. Please drop again.") } + if !ids.contains(song) && !addIfNeeded { throw LibraryError.message("This Tonight request no longer exists") } + if addIfNeeded { + try assignSong(song,singer:singer) + try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) VALUES(?,?,?,0)",[String(venue),String(singer),String(song)]) + try execute("INSERT INTO queue(venue,song,singer,position) SELECT ?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?) WHERE NOT EXISTS(SELECT 1 FROM queue WHERE venue=? AND singer=? AND song=?)",[String(venue),String(song),String(singer),String(venue),String(venue),String(singer),String(song)]) + } + if before != song { + ids.removeAll { $0 == song } + if let before,let index = ids.firstIndex(of:before) { ids.insert(song,at:index) } else { ids.append(song) } + } + for (index,id) in ids.enumerated() { try execute("UPDATE singer_tonight SET position=? WHERE venue=? AND singer=? AND song=?",[String(index),String(venue),String(singer),String(id)]) } + let all = try queue(venue:venue) + let slots = all.indices.filter { all[$0].singerID == singer } + let rank = Dictionary(uniqueKeysWithValues:ids.enumerated().map { ($0.element,$0.offset) }) + let requests = slots.enumerated().map { (original:$0.offset,row:all[$0.element]) }.sorted { + let a = rank[$0.row.songID] ?? Int.max,b = rank[$1.row.songID] ?? Int.max + return a == b ? $0.original < $1.original : a < b + } + var ordered = all + for (slot,request) in zip(slots,requests) { ordered[slot] = request.row } + for (index,row) in ordered.enumerated() { try execute("UPDATE queue SET position=? WHERE id=?",[String(index),String(row.id)]) } + try execute("COMMIT") + } catch { try? execute("ROLLBACK");throw error } + } +} diff --git a/backups/before-kjmix-20261103-100337/KJamsClone/Player.swift b/backups/before-kjmix-20261103-100337/KJamsClone/Player.swift new file mode 100644 index 0000000..b289215 --- /dev/null +++ b/backups/before-kjmix-20261103-100337/KJamsClone/Player.swift @@ -0,0 +1,277 @@ +import SwiftUI +import AVFoundation +import CryptoKit + +/// CDG follows consumed source frames, so tempo adjustment preserves graphics timing. +@MainActor final class Player: ObservableObject { + @Published var image: CGImage? + @Published private(set) var waveform = [Float]() + @Published private(set) var waveformBands = [WaveformBands]() + @Published var lowGain: Double = 0 { didSet { updateTone() } } + @Published var midGain: Double = 0 { didSet { updateTone() } } + @Published var highGain: Double = 0 { didSet { updateTone() } } + @Published var filter: Double = 0 { didSet { updateTone() } } + @Published var toneEnabled = true { didSet { updateTone() } } + @Published private(set) var leftLevel: Float = 0 + @Published private(set) var rightLevel: Float = 0 + private let tone = AVAudioUnitEQ(numberOfBands:4) + private let meter = AudioLevelMeter() + private var metering = false + @Published private(set) var waveformLoading = false + @Published private(set) var waveformUnavailable = false + @Published private(set) var waveformFromCache = false + @Published private(set) var waveformSaved = false + private let waveformCacheDirectory: URL + private var audioURL: URL? + private var waveformTask: Task? + private var waveformGeneration = UUID() + @Published var playing = false + @Published var time: Double = 0 + @Published var duration: Double = 0 + @Published var title = "Select a song" + @Published var volume: Double = 0.8 { didSet { engine.mainMixerNode.outputVolume = Float(volume) } } + @Published var repeating = false + @Published var pitch: Double = 0 { didSet { effect.pitch = Float(pitch * 100) } } + @Published var tempo: Double = 1 { didSet { effect.rate = Float(tempo) } } + @Published var graphicsOffset: Double = 0 + private(set) var currentSongID: Int64? + var onFinished: (() -> Void)? + private let engine = AVAudioEngine() + private let node = AVAudioPlayerNode() + private let effect = AVAudioUnitTimePitch() + private var file: AVAudioFile? + private var anchor: Double = 0 + private var generation = UUID() + private var cdg = Data() + private var decoder = CDGDecoder() + private var timer: Timer? + init(waveformCacheDirectory: URL? = nil) { + self.waveformCacheDirectory = waveformCacheDirectory ?? FileManager.default.urls(for:.applicationSupportDirectory,in:.userDomainMask)[0].appendingPathComponent("KJamsClone/Waveforms") + engine.attach(node);engine.attach(effect);engine.attach(tone);updateTone() + } + func reanalyseWaveform() { if let audioURL { analyseWaveform(audioURL,force:true) } } + func resetTone() { lowGain = 0; midGain = 0; highGain = 0; filter = 0; toneEnabled = true } + private func updateTone() { + AudioTone.configure(tone,sampleRate:file?.processingFormat.sampleRate ?? 44100,low:lowGain,mid:midGain,high:highGain,filter:filter,enabled:toneEnabled) + } + func load(_ song: Song, preference: SongPreference = .init()) throws { + stop() + do { + let loaded = try AVAudioFile(forReading:URL(fileURLWithPath:song.audio)) + let graphics = try song.cdg.map { try Data(contentsOf:URL(fileURLWithPath:$0)) } ?? Data() + guard loaded.length > 0 else { throw LibraryError.message("Audio file is empty") } + engine.connect(node,to:effect,format:loaded.processingFormat) + engine.connect(effect,to:tone,format:loaded.processingFormat) + engine.connect(tone,to:engine.mainMixerNode,format:loaded.processingFormat) + file = loaded; cdg = graphics; decoder = CDGDecoder() + duration = Double(loaded.length)/loaded.processingFormat.sampleRate + title = song.title; currentSongID = song.id + pitch = preference.pitch; tempo = preference.tempo + updateTone() + let meter = self.meter + engine.mainMixerNode.installTap(onBus:0,bufferSize:1024,format:nil) { buffer, _ in meter.consume(buffer) } + metering = true + engine.mainMixerNode.outputVolume = Float(volume) + engine.prepare(); try engine.start() + schedule(at:0,play:true); startTimer(); tick() + audioURL = URL(fileURLWithPath:song.audio) + analyseWaveform(audioURL!) + } catch { stop(); throw error } + } + private func analyseWaveform(_ url: URL, force: Bool = false) { + waveformTask?.cancel() + let token = UUID();waveformGeneration = token;waveformLoading = true;waveformUnavailable = false + let directory = waveformCacheDirectory + waveformTask = Task.detached(priority:.utility) { [weak self] in + let result: Result + var cached = false,saved = false + do { + if !force,let analysis = try? WaveformCache.load(audio:url,directory:directory) { + result = .success(analysis);cached = true;saved = true + } else { + let analysis = try AudioWaveform.analyse(url:url,cancelled:{ Task.isCancelled }) + if !Task.isCancelled { saved = (try? WaveformCache.save(analysis,audio:url,directory:directory)) != nil } + result = .success(analysis) + } + } catch { result = .failure(error) } + guard !Task.isCancelled else { return } + let loadedFromCache = cached,analysisSaved = saved + await MainActor.run { [weak self] in + guard let self,self.waveformGeneration == token else { return } + self.waveformLoading = false;self.waveformFromCache = loadedFromCache;self.waveformSaved = analysisSaved + switch result { + case .success(let analysis):self.waveform = analysis.levels;self.waveformBands = analysis.bands + case .failure:self.waveformUnavailable = true + } + } + } + } + private func schedule(at seconds: Double, play: Bool) { + guard let file else { return } + generation = UUID(); let token = generation + node.stop() + let rate = file.processingFormat.sampleRate + let start = min(file.length,max(0,AVAudioFramePosition(seconds*rate))) + anchor = Double(start)/rate; time = anchor + guard start < file.length else { playing = false; render(); return } + node.scheduleSegment(file,startingFrame:start,frameCount:AVAudioFrameCount(min(file.length-start,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack) { [weak self] _ in + Task { @MainActor in + guard let self, self.generation == token else { return } + if self.repeating { self.schedule(at:0,play:true) } + else { + self.playing = false; self.time = self.duration; self.render() + self.timer?.invalidate(); self.timer = nil; self.onFinished?() + } + } + } + if play { node.play() }; playing = play; render() + } + private func startTimer() { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval:1.0/30,repeats:true) { [weak self] _ in Task { @MainActor in self?.tick() } } + } + func toggle() { + guard file != nil else { return } + if playing { tick(); node.pause(); playing = false } + else if time >= duration { schedule(at:0,play:true); startTimer() } + else { node.play(); playing = true; startTimer() } + } + func stop() { + waveformTask?.cancel(); waveformTask = nil; waveformGeneration = UUID() + waveform = [];waveformBands = [];waveformLoading = false;waveformUnavailable = false;waveformFromCache = false;waveformSaved = false;audioURL = nil + generation = UUID(); timer?.invalidate(); timer = nil; node.stop(); engine.stop() + if metering { engine.mainMixerNode.removeTap(onBus:0); metering = false } + meter.reset(); leftLevel = 0; rightLevel = 0 + engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect); engine.disconnectNodeOutput(tone) + file = nil; playing = false; time = 0; anchor = 0; duration = 0; image = nil; currentSongID = nil; title = "Select a song" + } + func seek(_ value: Double) { + guard file != nil, value.isFinite else { return } + schedule(at:min(max(value,0),duration),play:playing); startTimer() + } + private func render() { decoder.render(cdg,at:max(0,time+graphicsOffset)); image = cdg.isEmpty ? nil : decoder.image() } + private func tick() { + let levels = meter.snapshot() + leftLevel = playing ? levels.0 : 0; rightLevel = playing ? levels.1 : 0 + if playing, let file, let renderTime = node.lastRenderTime, let playerTime = node.playerTime(forNodeTime:renderTime) { + time = min(duration,max(0,anchor+Double(playerTime.sampleTime)/file.processingFormat.sampleRate)) + } + render() + } +} + +/// Streams fixed-size PCM chunks into a bounded RMS envelope, independent of playback. +/// A separate AVAudioFile keeps analysis away from the player's scheduling/read position. +enum AudioWaveform { + struct Analysis: Codable, Sendable { var levels: [Float]; var bands: [WaveformBands] } + static func read(url: URL, bins: Int = 4096, cancelled: () -> Bool = { false }) throws -> [Float] { + try analyse(url:url,bins:bins,cancelled:cancelled).levels + } + static func analyse(url: URL, bins: Int = 4096, cancelled: () -> Bool = { false }) throws -> Analysis { + guard bins > 0 else { return .init(levels:[],bands:[]) } + let file = try AVAudioFile(forReading: url, commonFormat: .pcmFormatFloat32, interleaved: false) + guard file.length > 0, let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: 16384) else { return .init(levels:[],bands:[]) } + var energy = [Double](repeating: 0, count: bins) + var counts = [Int](repeating: 0, count: bins) + var position: Int64 = 0 + let channels = Int(file.processingFormat.channelCount) + let rate = file.processingFormat.sampleRate + let lowAlpha = exp(-2*Double.pi*250/rate), highAlpha = exp(-2*Double.pi*2500/rate) + var lowState = [Double](repeating:0,count:channels), highState = lowState + var lows = [Double](repeating:0,count:bins), mids = lows, highs = lows + while position < file.length { + if cancelled() { throw CancellationError() } + try file.read(into: buffer, frameCount: AVAudioFrameCount(min(16384, file.length-position))) + let frames = Int(buffer.frameLength) + guard frames > 0, let data = buffer.floatChannelData else { break } + for frame in 0.. 0 ? sqrt($0/Double($1)) : 0 } + let peak = rms.max() ?? 0 + let levels = peak > 0 ? rms.map { Float(pow(min(1,$0/peak),0.7)) } : [Float](repeating:0,count:bins) + let bands = (0.. (Float,Float) { lock.lock();defer { lock.unlock() };return levels } + func reset() { lock.lock();levels = (0,0);lock.unlock() } +} + +/// Persistent analysis cache: file path, size, modification time and analyser version form its key. +/// Invalid or obsolete entries are treated as misses, never as playback errors. +enum WaveformCache { + static func location(audio: URL,directory: URL) throws -> URL { + let values = try FileManager.default.attributesOfItem(atPath:audio.path) + let stamp = (values[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 + let size = (values[.size] as? NSNumber)?.int64Value ?? 0 + let signature = "rgb-rms-v2-4096|\(audio.standardizedFileURL.path)|\(size)|\(stamp)" + let key = SHA256.hash(data:Data(signature.utf8)).map { String(format:"%02x",$0) }.joined() + return directory.appendingPathComponent(key+".waveform") + } + static func load(audio: URL,directory: URL) throws -> AudioWaveform.Analysis { + let url = try location(audio:audio,directory:directory) + let data = try Data(contentsOf:url) + guard data.count < 4_000_000 else { throw LibraryError.message("Invalid waveform cache") } + let analysis = try PropertyListDecoder().decode(AudioWaveform.Analysis.self,from:data) + guard analysis.levels.count == 4096,analysis.bands.count == 4096, + analysis.levels.allSatisfy({ $0.isFinite && $0 >= 0 && $0 <= 1 }), + analysis.bands.allSatisfy({ $0.low.isFinite && $0.mid.isFinite && $0.high.isFinite && $0.low >= 0 && $0.mid >= 0 && $0.high >= 0 }) else { throw LibraryError.message("Invalid waveform cache") } + return analysis + } + static func save(_ analysis: AudioWaveform.Analysis,audio: URL,directory: URL) throws { + try FileManager.default.createDirectory(at:directory,withIntermediateDirectories:true) + let encoder = PropertyListEncoder();encoder.outputFormat = .binary + try encoder.encode(analysis).write(to:location(audio:audio,directory:directory),options:.atomic) + } +} diff --git a/backups/before-kjmix-20261103-100337/scripts/build.sh b/backups/before-kjmix-20261103-100337/scripts/build.sh new file mode 100755 index 0000000..434cd7e --- /dev/null +++ b/backups/before-kjmix-20261103-100337/scripts/build.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." +xcodebuild -project KJamsClone.xcodeproj -scheme KJamsClone -configuration Debug -destination 'platform=macOS' -derivedDataPath "${DERIVED_DATA:-$PWD/build}" CODE_SIGNING_ALLOWED=NO build "$@" diff --git a/backups/before-kjmix-20261103-100337/scripts/generate-test-media.py b/backups/before-kjmix-20261103-100337/scripts/generate-test-media.py new file mode 100644 index 0000000..f6b74ee --- /dev/null +++ b/backups/before-kjmix-20261103-100337/scripts/generate-test-media.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Original synthetic MP3+G test media. Regeneration requires ffmpeg; playback does not.""" +from pathlib import Path +import wave,struct,math,subprocess,zipfile,shutil +root=Path(__file__).resolve().parents[1]/'Fixtures';root.mkdir(exist_ok=True) +font={ +'A':['01110','10001','10001','11111','10001','10001','10001'], +'C':['01111','10000','10000','10000','10000','10000','01111'], +'D':['11110','10001','10001','10001','10001','10001','11110'], +'E':['11111','10000','10000','11110','10000','10000','11111'], +'F':['11111','10000','10000','11110','10000','10000','10000'], +'G':['01111','10000','10000','10111','10001','10001','01111'], +'H':['10001','10001','10001','11111','10001','10001','10001'], +'I':['11111','00100','00100','00100','00100','00100','11111'], +'J':['00111','00010','00010','00010','10010','10010','01100'], +'K':['10001','10010','10100','11000','10100','10010','10001'], +'L':['10000','10000','10000','10000','10000','10000','11111'], +'M':['10001','11011','10101','10101','10001','10001','10001'], +'N':['10001','11001','10101','10011','10001','10001','10001'], +'O':['01110','10001','10001','10001','10001','10001','01110'], +'P':['11110','10001','10001','11110','10000','10000','10000'], +'R':['11110','10001','10001','11110','10100','10010','10001'], +'S':['01111','10000','10000','01110','00001','00001','11110'], +'T':['11111','00100','00100','00100','00100','00100','00100'], +'U':['10001','10001','10001','10001','10001','10001','01110'], +'V':['10001','10001','10001','10001','10001','01010','00100'], +'W':['10001','10001','10001','10101','10101','11011','10001'], +'Y':['10001','10001','01010','00100','00100','00100','00100'], +'3':['11110','00001','00001','01110','00001','00001','11110'], +' ':['00000']*7, +} +def packet(inst,data):return bytes([9,inst,0,0]+(data+[0]*16)[:16]+[0]*4) +packets=[bytes(24) for _ in range(6000)] +def put(index,seq): + for p in seq: + assert packets[index]==bytes(24),'overlapping instructions' + packets[index]=p;index+=1 + return index +colors=[0x013,0xfff,0xfd0,0x0cf]+[0]*12 +def palette(offset): + data=[] + for c in colors[offset:offset+8]:data.extend([(c>>6)&63,c&63]) + return packet(30 if offset==0 else 31,data) +put(0,[palette(0),palette(8),packet(1,[0,0]),packet(2,[0])]) +def frame(lines): + pixels=[[0]*300 for _ in range(216)] + for line,(text,y) in enumerate(lines): + x=(300-len(text)*12)//2 + for char in text: + for gy,row in enumerate(font[char]): + for gx,bit in enumerate(row): + if bit=='1': + for dy in range(2): + for dx in range(2):pixels[y+gy*2+dy][x+gx*2+dx]=1 + x+=12 + return pixels +def tiles(pixels,xor=False): + result=[] + for row in range(1,17): + for col in range(1,49): + bits=[] + for y in range(12): + v=0 + for x in range(6):v=(v<<1)|pixels[row*12+y][col*6+x] + bits.append(v) + if any(bits):result.append(packet(38 if xor else 6,[0,3 if xor else 1,row,col]+bits)) + return result +scenes=[[("KJAMS CLONE",66),("MP3 PLUS G TEST",114)],[("ONE TWO THREE FOUR",66),("SING ALONG",114)],[("WITH THE TEST TONE",66),("PLAY PAUSE SEEK",114)],[("MP3 PLUS G TEST",66),("TEST COMPLETE",114)]] +for index,lines in enumerate(scenes): + start=index*1500+(5 if index==0 else 0) + pixels=frame(lines) + put(start,([packet(1,[0,0])] if index else [])+tiles(pixels)) + # Highlight each word after the initial white text has been drawn. + for line,(text,y) in enumerate(lines): + left=(300-len(text)*12)//2 + words=text.split();cursor=0 + for wi,word in enumerate(words): + mask=[[0]*300 for _ in range(216)] + for py in range(y,y+14): + for px in range(left+cursor*12,left+(cursor+len(word))*12):mask[py][px]=pixels[py][px] + put(index*1500+240+line*600+wi*100,tiles(mask,True)) + cursor+=len(word)+1 +(root/'TestTrack.cdg').write_bytes(b''.join(packets)) +rate=44100;duration=20;notes=[261.63,329.63,392.0,523.25,392.0,329.63,293.66,261.63] +with wave.open(str(root/'TestTrack.wav'),'w') as out: + out.setparams((1,2,rate,0,'NONE','not compressed')) + for beat in range(duration*2): + freq=notes[beat%len(notes)] + data=bytearray() + for i in range(rate//2): + t=i/rate;fade=min(1,t/0.015,(0.5-t)/0.04) + data.extend(struct.pack(' Void) { do { try work() } catch { self.error = error.localizedDescription } } + func refresh() throws { + guard let library else { throw LibraryError.message("Library unavailable") } + songs = try library.songs(); singers = try library.entities("singers"); venues = try library.entities("venues") + playlists = try library.entities("playlists"); currentVenue = try library.currentVenue() + venueSingers = try currentVenue.map { try library.members(of:$0) } ?? singers + if let selectedSinger, !venueSingers.contains(where: { $0.id == selectedSinger }) { self.selectedSinger = nil } + queue = try library.queue(venue:currentVenue); history = try library.history(venue:currentVenue) + } + func chooseVenue(_ id: Int64?) { + perform { + guard let library else { throw LibraryError.message("Library unavailable") } + try library.selectVenue(id); selectedSinger = nil; try refresh() + } + } + func membership(singer: Int64, included: Bool) { + perform { + guard let library, let currentVenue else { throw LibraryError.message("Select a current venue first") } + try library.setMembership(singer:singer,venue:currentVenue,included:included); try refresh() + } + } + @discardableResult func createEntity(_ kind: EntityKind, name: String) throws -> Int64 { + guard let library else { throw LibraryError.message("Library unavailable") } + let trimmed = name.trimmingCharacters(in:.whitespacesAndNewlines) + try library.addEntity(kind.table,name:trimmed) + guard let entity = try library.entities(kind.table).first(where: { $0.name == trimmed }) else { throw LibraryError.message("Could not load new entry") } + if kind == .singer { + if let currentVenue { try library.setMembership(singer:entity.id,venue:currentVenue,included:true) } + selectedSinger = entity.id + } else { try library.selectVenue(entity.id); selectedSinger = nil } + try refresh(); return entity.id + } + func rename(_ table: String, id: Int64, name: String) { perform { try library?.rename(table,id:id,name:name); try refresh() } } + func delete(_ table: String, id: Int64) { perform { try library?.deleteEntity(table,id:id); try refresh() } } + func preference(_ song: Int64) -> SongPreference { + guard let library, let selectedSinger else { return .init() } + return (try? library.preference(song:song,singer:selectedSinger)) ?? .init() + } + func savePreference(song: Int64, value: SongPreference) { + perform { + guard let library, let selectedSinger else { throw LibraryError.message("Select a singer to save their song preferences") } + try library.savePreference(song:song,singer:selectedSinger,value:value); try refresh() + } + } + func play(_ song: Song, singer: Int64? = nil) throws { + guard let library else { throw LibraryError.message("Library unavailable") } + let singer = singer ?? selectedSinger + if let singer, let currentVenue, try !library.members(of:currentVenue).contains(where: { $0.id == singer }) { throw LibraryError.message("This singer is not in the current venue") } + let preference = try singer.map { try library.preference(song:song.id,singer:$0) } ?? SongPreference() + try player.load(song,preference:preference) + do { try library.record(song.id,singer:singer,venue:currentVenue); selectedSinger = singer; try refresh() } + catch { player.stop(); throw error } + } + func enqueue(_ song: Int64) { perform { try library?.enqueue(song:song,singer:selectedSinger,venue:currentVenue); try refresh() } } + func playQueueNext() { + perform { + guard let row = queue.first, let song = songs.first(where: { $0.id == row.songID }) else { return } + // Explicitly clear selection for unassigned queue entries. + selectedSinger = row.singerID + try play(song,singer:row.singerID); try library?.removeQueue(row.id); try refresh() + } + } + func moveQueue(_ id: Int64, by offset: Int) { perform { if let currentVenue { try library?.moveQueue(id,venue:currentVenue,by:offset); try refresh() } } } + func removeQueue(_ id: Int64) { perform { try library?.removeQueue(id); try refresh() } } + func createPlaylist(_ name: String) { perform { try library?.addEntity("playlists",name:name); try refresh() } } + func addToPlaylist(_ playlist: Int64, song: Int64) { perform { try library?.addToPlaylist(playlist,song:song); try refresh() } } + func importFiles(_ urls: [URL]) { perform { guard let library else { throw LibraryError.message("Library unavailable") }; try Importer(media:root.appendingPathComponent("Media")).run(urls,library:library); try refresh() } } +} +@main struct KJamsCloneApp: App { + @StateObject private var model = AppModel() + var body: some Scene { + Window("KJamsClone", id: "main") { + LibraryView(model: model).frame(minWidth: 1060, minHeight: 680).preferredColorScheme(.dark) + }.defaultSize(width: 1260, height: 840) + .commands { + CommandGroup(replacing: .newItem) { + Button("New Singer…") { model.creating = .singer }.keyboardShortcut("n", modifiers: [.command, .shift]) + Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option]) + } + } + Window("Video", id: "lyrics") { + LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark) + }.defaultSize(width: 600, height: 400) + } +} +private enum Classic { + static let frame = Color(red: 0.15, green: 0.15, blue: 0.15) + static let navy = Color(red: 0.008, green: 0.065, blue: 0.16) + static let blue = Color(red: 0.015, green: 0.17, blue: 0.42) + static let header = Color(white: 0.115) + static let line = Color(white: 0.42) +} +struct LyricsView: View { + @ObservedObject var player: Player + var body: some View { + ZStack { + Color.black + if let image = player.image { + Image(decorative: image, scale: 1).interpolation(.none).resizable().aspectRatio(1.5, contentMode: .fit) + } + } + } +} +/// This is another view of the same published CGImage used by the Video window. +/// It owns no decoder, audio player or clock, so seeking/pause/tempo stay identical. +private struct SidebarVideoMonitor: View { + let player: Player + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Label("Video Monitor", systemImage: "tv") + .font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7)) + LyricsView(player: player) + .aspectRatio(1.5, contentMode: .fit) + .clipped() + .overlay(Rectangle().strokeBorder(.white.opacity(0.25), lineWidth: 1)) + .accessibilityLabel("Video monitor, synchronized with the Video window") + }.frame(maxWidth: .infinity) + } +} +private struct SilverSquareStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label.foregroundStyle(Color(white: 0.25)).padding(.horizontal, 6).padding(.vertical, 3) + .background(LinearGradient(colors: [Color(white: 0.95), Color(white: 0.64)], startPoint: .top, endPoint: .bottom), in: RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Color(white: 0.45), lineWidth: 1.5)) + .overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(.white.opacity(0.7)).padding(3)) + .opacity(configuration.isPressed ? 0.7 : 1) + } +} +private struct SilverButton: View { + let symbol: String + let label: String + var size: CGFloat = 40 + var active = false + var enabled = true + let action: () -> Void + var body: some View { + Button(action: action) { + Image(systemName: symbol).font(.system(size: size * 0.41, weight: .semibold)) + .foregroundStyle(active ? Color.blue : Color(white: 0.24)) + .frame(width: size, height: size) + .background(LinearGradient(colors: [.white, Color(white: 0.85), Color(white: 0.63)], startPoint: .top, endPoint: .bottom), in: Circle()) + .overlay(Circle().strokeBorder(Color(white: 0.45), lineWidth: 1.5)) + .overlay(Circle().strokeBorder(.white.opacity(0.65), lineWidth: 1).padding(3)) + .shadow(color: .black.opacity(0.7), radius: 1, y: 1) + }.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.72) + .help(label).accessibilityLabel(label) + } +} +struct LibraryView: View { + @ObservedObject var model: AppModel + @State private var selected: Int64? + @State private var search = "" + @State private var name = "" + @State private var importing = false + @State private var source = "Library" + @Environment(\.openWindow) private var openWindow + private var visibleSongs: [Song] { model.songs.filter { search.isEmpty || ($0.title+" "+$0.artist+" "+$0.album).localizedCaseInsensitiveContains(search) } } + private var selectedSong: Song? { model.songs.first { $0.id == selected } } + var body: some View { + VStack(spacing: 0) { + TransportView(player: model.player, search: $search, hasSelection: selectedSong != nil, + play: playSelected, next: playNext, display: { openWindow(id: "lyrics") }) + .padding(.horizontal, 18).padding(.top, 18).padding(.bottom, 22) + HSplitView { + sidebar.frame(minWidth: 190, idealWidth: 220, maxWidth: 240) + VStack(spacing: 12) { + if source == "Library" { songTable } + else if source == "Queue" { queuePane } + else if source == "History" { historyPane } + else if source == "Playlists" { playlistPane } + else { directory } + detailPanel.frame(height: 135) + }.padding(.leading, 12).frame(minWidth: 780) + }.padding(.horizontal, 18) + footer.padding(.horizontal, 18).padding(.vertical, 12) + } + .background(Classic.frame) + .sheet(item: $model.creating) { kind in + AddEntitySheet(model: model, kind: kind) { id in + if kind == .singer { model.selectedSinger = id } else { model.chooseVenue(id) } + } + } + .fileImporter(isPresented: $importing, allowedContentTypes: [.audio, .zip, UTType(filenameExtension: "cdg") ?? .data], allowsMultipleSelection: true) { result in + switch result { + case .success(let urls): model.importFiles(urls) + case .failure(let error): model.error = error.localizedDescription + } + } + .alert("KJamsClone", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { + Button("OK") { model.error = nil } + } message: { Text(model.error ?? "") } + } + private func sourceCount(_ source: String) -> Int { + switch source { + case "Library": return model.songs.count + case "Singers": return model.venueSingers.count + case "Venues": return model.venues.count + case "Queue": return model.queue.count + case "History": return model.history.count + default: return model.playlists.count + } + } + private var sidebar: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Source").font(.system(size: 15, weight: .semibold)).padding(.horizontal, 10).frame(height: 31) + .frame(maxWidth: .infinity, alignment: .leading).background(Classic.header) + ForEach([("Library", "music.note.house.fill", Color.yellow), ("Singers", "person.2.fill", Color.green), ("Venues", "building.2.fill", Color.cyan), ("Queue", "list.number", Color.orange), ("History", "clock.arrow.circlepath", Color.purple), ("Playlists", "music.note.list", Color.mint)], id: \.0) { item in + Button { source = item.0 } label: { + HStack(spacing: 10) { + Image(systemName: item.1).foregroundStyle(item.2).frame(width: 22) + Text(item.0).font(.system(size: 16, weight: .medium)) + Spacer() + Text("\(sourceCount(item.0))") + .font(.system(size: 11)).foregroundStyle(.white.opacity(0.6)) + }.padding(.horizontal, 12).frame(height: 34).contentShape(Rectangle()) + .background(source == item.0 ? Color(red: 0, green: 0.36, blue: 1) : .clear) + }.buttonStyle(.plain) + } + Spacer(minLength: 12) + SidebarVideoMonitor(player: model.player).padding(10) + }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) + } + private var songTable: some View { + GeometryReader { geometry in + ScrollView(.horizontal) { + VStack(spacing: 0) { + tableHeader(width: max(geometry.size.width, 790)) + ScrollView(.vertical) { + LazyVStack(spacing: 0) { + ForEach(visibleSongs) { song in songRow(song, width: max(geometry.size.width, 790)) } + }.frame(maxWidth: .infinity, alignment: .topLeading) + } + }.frame(width: max(geometry.size.width, 790), height: geometry.size.height) + } + }.background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) + } + private func widths(_ total: CGFloat) -> [CGFloat] { + [42, max(200, total - 598), 100, 100, 76, 86, 72, 122] + } + private func tableHeader(width: CGFloat) -> some View { + let sizes = widths(width) + return HStack(spacing: 0) { + ForEach(Array(["PLI", "Song Name", "Artist", "Album", "Label", "Key Adjust", "Tempo", "Rating"].enumerated()), id: \.offset) { index, title in + Text(title).font(.system(size: 13, weight: .medium)).lineLimit(1) + .padding(.leading, 7).frame(width: sizes[index], height: 30, alignment: .leading) + .overlay(alignment: .trailing) { Rectangle().fill(Classic.line).frame(width: 1).padding(.vertical, 4) } + } + }.foregroundStyle(.white).background(Classic.header) + .overlay(alignment: .bottom) { Rectangle().fill(Color(white: 0.65)).frame(height: 1) } + } + private func cellText(_ song: Song, column: Int) -> String { + switch column { + case 2: return song.artist.isEmpty ? "—" : song.artist + case 3: return song.album.isEmpty ? "—" : song.album + case 4: return song.label.isEmpty ? (song.cdg == nil ? "Audio" : URL(fileURLWithPath:song.audio).pathExtension.uppercased()+"+G") : song.label + case 5: return String(format:"%+.0f",model.preference(song.id).pitch) + default: return String(format:"%.0f%%",model.preference(song.id).tempo*100) + } + } + private func songRow(_ song: Song, width: CGFloat) -> some View { + let sizes = widths(width) + return HStack(spacing: 0) { + Image(systemName: song.cdg == nil ? "music.note" : "music.note.tv") + .foregroundStyle(.white.opacity(0.75)).frame(width: sizes[0]) + Text(song.title).lineLimit(1).padding(.leading, 7).frame(width: sizes[1], alignment: .leading) + ForEach(2..<7) { index in + Text(cellText(song, column: index)) + .foregroundStyle(.white.opacity(0.5)).padding(.leading, 7).frame(width: sizes[index], alignment: .leading) + } + Menu { + ForEach(0..<6) { value in + Button(value == 0 ? "Unrated" : String(repeating: "★", count: value)) { + model.perform { try model.library?.rate(song.id, value); try model.refresh() } + } + } + } label: { + Text(song.rating == 0 ? "☆☆☆☆☆" : String(repeating: "★", count: song.rating) + String(repeating: "☆", count: 5 - song.rating)) + .font(.system(size: 13)).foregroundStyle(song.rating == 0 ? Color.gray : Color.yellow) + }.menuStyle(.borderlessButton).frame(width: sizes[7] - 14).padding(.horizontal, 7).help("Set song rating") + }.font(.system(size: 13)).foregroundStyle(.white).frame(height: 30) + .background(selected == song.id ? Color(red: 0, green: 0.28, blue: 0.72) : .clear) + .contentShape(Rectangle()).onTapGesture(count: 2) { selected = song.id; playSelected() } + .onTapGesture { selected = song.id } + .contextMenu { + Button("Play") { selected = song.id; playSelected() } + Button("Add to Current Venue Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Menu("Add to Playlist") { ForEach(model.playlists) { playlist in Button(playlist.name) { model.addToPlaylist(playlist.id,song:song.id) } } } + } + } + @State private var managingRoster = false + @State private var rosterNewName = "" + @State private var editingEntity: PersonPlace? + @State private var renameText = "" + @State private var pendingDelete: PersonPlace? + private var directory: some View { + VStack(alignment:.leading,spacing:0) { + HStack { + Text(source == "Singers" ? "Singers · \(model.currentVenueName)" : "Venues").font(.headline) + Spacer() + if source == "Singers" { Button("Manage Venue Singers…") { managingRoster = true }.disabled(model.currentVenue == nil) } + Button(source == "Singers" ? "New Singer…" : "New Venue…") { model.creating = source == "Singers" ? .singer : .venue } + }.padding(12).background(Classic.header) + ScrollView { + VStack(alignment:.leading,spacing:0) { + if source == "Singers" && model.venueSingers.isEmpty { + Text("No singers at this venue. Add a singer or use Manage Venue Singers to include existing singers.").foregroundStyle(.secondary).padding() + } + ForEach(source == "Singers" ? model.venueSingers : model.venues) { entity in + HStack { + Button { + if source == "Singers" { model.selectedSinger = entity.id } else { model.chooseVenue(entity.id) } + } label: { + HStack { + Image(systemName:source == "Singers" ? "person.fill" : "building.2.fill") + Text(entity.name); Spacer() + if entity.id == (source == "Singers" ? model.selectedSinger : model.currentVenue) { + Text(source == "Singers" ? "Selected" : "Current venue").font(.caption).foregroundStyle(.cyan) + Image(systemName:"checkmark.circle.fill").foregroundStyle(.cyan) + } + }.contentShape(Rectangle()) + }.buttonStyle(.plain) + Button("Rename") { renameText = entity.name; editingEntity = entity } + Button("Delete",role:.destructive) { pendingDelete = entity } + }.padding(12) + } + } + } + HStack { + TextField(source == "Singers" ? "Singer name" : "Venue name",text:$name).textFieldStyle(.roundedBorder).onSubmit(addEntity) + Button(source == "Singers" ? "Add Singer" : "Add Venue",action:addEntity).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + }.padding(12) + }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + .sheet(isPresented:$managingRoster) { rosterSheet } + .sheet(item:$editingEntity) { entity in + VStack(alignment:.leading,spacing:16) { + Text("Rename \(source == "Singers" ? "Singer" : "Venue")").font(.headline) + TextField("Name",text:$renameText).textFieldStyle(.roundedBorder) + HStack { Button("Cancel") { editingEntity = nil }; Spacer(); Button("Save") { model.rename(source == "Singers" ? "singers" : "venues",id:entity.id,name:renameText); editingEntity = nil }.disabled(renameText.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) } + }.padding(24).frame(width:360) + } + .confirmationDialog("Delete \(pendingDelete?.name ?? "entry")? Historical plays are kept. Venue memberships and queued entries for it will be removed.",isPresented:Binding(get:{ pendingDelete != nil },set:{ if !$0 { pendingDelete = nil } })) { + Button("Delete",role:.destructive) { if let entity = pendingDelete { model.delete(source == "Singers" ? "singers" : "venues",id:entity.id) }; pendingDelete = nil } + } + } + private var rosterSheet: some View { + VStack(alignment:.leading,spacing:16) { + Text("Singers at \(model.currentVenueName)").font(.title2.bold()) + Text("A singer can belong to any number of venues. Unchecking removes them from this venue only.").foregroundStyle(.secondary) + ScrollView { + VStack(alignment:.leading,spacing:12) { + ForEach(model.singers) { singer in + Toggle(singer.name,isOn:Binding(get:{ model.venueSingers.contains { $0.id == singer.id } },set:{ model.membership(singer:singer.id,included:$0) })) + } + }.frame(maxWidth:.infinity,alignment:.leading) + }.frame(minHeight:120,maxHeight:280) + HStack { + TextField("New singer name",text:$rosterNewName).textFieldStyle(.roundedBorder) + Button("Add") { model.perform { _ = try model.createEntity(.singer,name:rosterNewName); rosterNewName = "" } }.disabled(rosterNewName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + Spacer(); Button("Done") { managingRoster = false }.keyboardShortcut(.defaultAction) + } + }.padding(24).frame(width:460) + } + private var detailPanel: some View { + VStack(alignment:.leading,spacing:9) { + HStack(spacing:10) { + Picker("Current venue",selection:Binding(get:{ model.currentVenue },set:{ model.chooseVenue($0) })) { + Text("Select venue…").tag(nil as Int64?) + ForEach(model.venues) { Text($0.name).tag(Optional($0.id)) } + } + Button { model.creating = .venue } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add venue") + Picker("Singer",selection:$model.selectedSinger) { + Text("None").tag(nil as Int64?) + ForEach(model.venueSingers) { Text($0.name).tag(Optional($0.id)) } + } + Button { model.creating = .singer } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add singer") + Button("Queue Song") { if let selected { model.enqueue(selected) } }.disabled(selected == nil || model.currentVenue == nil) + } + PlaybackSettings(player:model.player,model:model,selectedSong:selected) + SeekBar(player:model.player) + }.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity) + .background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + @State private var playlistName = "" + @State private var selectedPlaylist: Int64? + private var queuePane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { Text("Queue · \(model.currentVenueName)").font(.headline); Spacer(); Toggle("Auto play next",isOn:$model.autoAdvance).toggleStyle(.checkbox); Button("Play Next") { model.playQueueNext() }.disabled(model.queue.isEmpty) } + ScrollView { + VStack(spacing:0) { + ForEach(Array(model.queue.enumerated()),id:\.element.id) { index,row in + HStack { + Text("\(index+1)").foregroundStyle(.secondary).frame(width:24) + Text(row.singer).frame(width:140,alignment:.leading) + Text(row.title).lineLimit(1); Spacer() + Button { model.moveQueue(row.id,by:-1) } label: { Image(systemName:"arrow.up") }.disabled(index == 0) + Button { model.moveQueue(row.id,by:1) } label: { Image(systemName:"arrow.down") }.disabled(index == model.queue.count-1) + Button("Remove") { model.removeQueue(row.id) } + }.padding(.vertical,8) + } + if model.queue.isEmpty { Text("Choose a venue and singer, select a library song, then click Queue Song.").foregroundStyle(.secondary).padding(.top,30) } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + @State private var historyAllVenues = false + private var displayedHistory: [Performance] { + if historyAllVenues { return (try? model.library?.history(venue:nil,singer:model.selectedSinger)) ?? [] } + return model.history.filter { model.selectedSinger == nil || $0.singer == model.singers.first(where: { $0.id == model.selectedSinger })?.name } + } + private var historyPane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { Text("History · \(historyAllVenues || model.currentVenue == nil ? "All venues" : model.currentVenueName)").font(.headline); Spacer(); Toggle("All venues",isOn:$historyAllVenues).toggleStyle(.checkbox) } + ScrollView { + LazyVStack(alignment:.leading,spacing:12) { + ForEach(displayedHistory) { row in + HStack { Text(row.date).font(.system(.caption,design:.monospaced)); Text(row.singer).frame(width:130,alignment:.leading); Text(row.song); Spacer(); Text(row.venue).foregroundStyle(.secondary) } + } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + private var playlistPane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { + Picker("Playlist",selection:$selectedPlaylist) { Text("Choose…").tag(nil as Int64?); ForEach(model.playlists) { Text($0.name).tag(Optional($0.id)) } } + TextField("New playlist name",text:$playlistName).textFieldStyle(.roundedBorder) + Button("Create") { model.createPlaylist(playlistName); selectedPlaylist = model.playlists.first { $0.name == playlistName.trimmingCharacters(in:.whitespacesAndNewlines) }?.id; playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + if let selectedPlaylist { + Button("Rename") { model.rename("playlists",id:selectedPlaylist,name:playlistName); playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + Button("Delete",role:.destructive) { model.delete("playlists",id:selectedPlaylist); self.selectedPlaylist = nil } } + } + ScrollView { + if let selectedPlaylist { + let ids = (try? model.library?.playlistSongIDs(selectedPlaylist)) ?? [] + VStack(alignment:.leading,spacing:10) { + ForEach(ids,id:\.self) { id in + if let song = model.songs.first(where: { $0.id == id }) { + HStack { + Text(song.title); Spacer() + Button("Play") { model.perform { try model.play(song) } } + Button("Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Button("Remove") { model.perform { try model.library?.removeFromPlaylist(selectedPlaylist,song:id); try model.refresh() } } + } + } + } + } + } + Text("Add songs using the library row’s context menu.").foregroundStyle(.secondary).padding(.top,20) + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + private var footer: some View { + HStack { + Button { importing = true } label: { Image(systemName: "plus").font(.system(size: 20, weight: .bold)).frame(width: 32, height: 24) } + .help("Import audio, MP3+G or ZIP").accessibilityLabel("Import songs") + RepeatButton(player: model.player).padding(.leading, 12) + Spacer() + Text("\(visibleSongs.count) \(visibleSongs.count == 1 ? "Song" : "Songs")").font(.system(size: 14, weight: .medium)) + Spacer() + Button { openWindow(id: "lyrics") } label: { Image(systemName: "rectangle.on.rectangle").frame(width: 32, height: 24) } + .help("Open video window").accessibilityLabel("Open video window") + }.buttonStyle(SilverSquareStyle()) + } + private func playSelected() { + guard let song = selectedSong else { return } + model.perform { try model.play(song) } + } + private func playNext() { + guard !visibleSongs.isEmpty else { return } + let index = visibleSongs.firstIndex { $0.id == selected } ?? -1 + selected = visibleSongs[(index + 1) % visibleSongs.count].id; playSelected() + } + private func addEntity() { + model.perform { + let kind: EntityKind = source == "Singers" ? .singer : .venue + let id = try model.createEntity(kind, name: name) + if kind == .singer { model.selectedSinger = id } else { model.chooseVenue(id) } + name = "" + } + } +} +private struct AddEntitySheet: View { + @ObservedObject var model: AppModel + let kind: EntityKind + let saved: (Int64) -> Void + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var error: String? + @FocusState private var focused: Bool + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("New \(kind.title)").font(.title2.bold()) + TextField("\(kind.title) name", text: $name).textFieldStyle(.roundedBorder) + .focused($focused).onSubmit(save) + if let error { Text(error).foregroundStyle(.red).font(.callout) } + HStack { + Spacer() + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Button("Add \(kind.title)", action: save).keyboardShortcut(.defaultAction) + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + }.padding(24).frame(width: 360).onAppear { focused = true } + } + private func save() { + do { let id = try model.createEntity(kind, name: name); saved(id); dismiss() } + catch { self.error = error.localizedDescription } + } +} +private struct TransportView: View { + @ObservedObject var player: Player + @Binding var search: String + let hasSelection: Bool + let play: () -> Void + let next: () -> Void + let display: () -> Void + var body: some View { + HStack(alignment: .top, spacing: 28) { + VStack(spacing: 15) { + HStack(spacing: 7) { + SilverButton(symbol: "backward.fill", label: "Back 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time - 10) } + SilverButton(symbol: player.playing ? "pause.fill" : "play.fill", label: player.playing ? "Pause" : "Play", size: 49, enabled: hasSelection || player.duration > 0) { + if player.duration > 0 { player.toggle() } else { play() } + } + SilverButton(symbol: "stop", label: "Stop", size: 46, enabled: player.duration > 0) { player.stop() } + SilverButton(symbol: "forward.fill", label: "Forward 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time + 10) } + SilverButton(symbol: "forward.end.fill", label: "Play next song", size: 26, enabled: hasSelection, action: next) + } + HStack(spacing: 9) { + Image(systemName: "speaker.fill").font(.system(size: 11)) + Slider(value: $player.volume, in: 0...1).controlSize(.small).tint(.gray).accessibilityLabel("Volume") + Image(systemName: "speaker.wave.3.fill").font(.system(size: 12)) + }.frame(width: 180) + }.frame(width: 220) + NowPlayingWaveform(player: player).padding(.top, 4) + HStack(spacing: 12) { + TextField("Search", text: $search).textFieldStyle(.plain).font(.system(size: 16)) + .padding(.horizontal, 8).frame(width: 145, height: 28).background(Color(white: 0.07), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).stroke(.white.opacity(0.1))) + SilverButton(symbol: "eye", label: "Open video window", size: 48, action: display) + }.padding(.top, 15) + }.foregroundStyle(.white).frame(height: 96) + } +} +private struct NowPlayingWaveform: View { + @ObservedObject var player: Player + private let teal = Color(red: 0.18, green: 0.83, blue: 0.75) + private let cyan = Color(red: 0.22, green: 0.74, blue: 0.97) + private var progress: Double { player.duration > 0 ? min(1, max(0, player.time / player.duration)) : 0 } + private func clock(_ value: Double) -> String { let n = max(0, Int(value)); return String(format: "%d:%02d", n/60, n%60) } + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(player.duration > 0 ? player.title : "Ready to play") + .font(.system(size: 13, weight: .semibold)).foregroundStyle(Color(white: 0.94)) + .lineLimit(1).truncationMode(.middle).frame(maxWidth: .infinity, alignment: .leading).help(player.title) + GeometryReader { geometry in + Canvas { context, size in + let mid = size.height/2 + var grid = Path() + for fraction in [0.25, 0.5, 0.75] { + let y = size.height*fraction; grid.move(to:CGPoint(x:0,y:y)); grid.addLine(to:CGPoint(x:size.width,y:y)) + } + context.stroke(grid,with:.color(.white.opacity(0.045)),lineWidth:1) + if !player.waveform.isEmpty { + let count = max(1, Int(size.width/3)) + let step = size.width/CGFloat(count) + for bar in 0.. 0, geometry.size.width > 0 else { return } + player.seek(min(1,max(0,value.location.x/geometry.size.width))*player.duration) + }) + .help("Click the waveform to seek") + .accessibilityLabel("Audio waveform") + .accessibilityValue("\(clock(player.time)) of \(clock(player.duration))") + .accessibilityAdjustableAction { direction in + if direction == .increment { player.seek(player.time+5) } else if direction == .decrement { player.seek(player.time-5) } + } + }.frame(height:28) + HStack(spacing:5) { + Circle().fill(player.playing ? teal : Color(red:0.9,green:0.68,blue:0.3)).frame(width:4,height:4) + Text(player.duration == 0 ? "NO TRACK LOADED" : player.playing ? "PLAYING" : "PAUSED") + .tracking(1) + if player.waveformUnavailable { Text("· Waveform unavailable").foregroundStyle(.secondary) } + Spacer(minLength:0) + Text("\(clock(player.time)) / \(clock(player.duration))").monospacedDigit() + }.font(.system(size:9,weight:.medium)).foregroundStyle(Color(red:0.57,green:0.67,blue:0.79)) + }.padding(.horizontal,12).padding(.vertical,9) + .frame(maxWidth:.infinity).frame(height:86) + .background(LinearGradient(colors:[Color(red:0.035,green:0.065,blue:0.105),Color(red:0.05,green:0.09,blue:0.15)],startPoint:.top,endPoint:.bottom)) + .overlay(Rectangle().strokeBorder(Color(red:0.21,green:0.29,blue:0.38),lineWidth:1)) + } +} +private struct PlaybackSettings: View { + @ObservedObject var player: Player + @ObservedObject var model: AppModel + let selectedSong: Int64? + @State private var personalRating = 0 + var body: some View { + HStack(spacing:10) { + Text("Key") + Picker("Key",selection:$player.pitch) { ForEach(-12..<13) { Text(String(format:"%+d",$0)).tag(Double($0)) } }.labelsHidden().frame(width:70) + Text("Tempo") + Slider(value:$player.tempo,in:0.5...1.5,step:0.05).frame(width:85) + Text(String(format:"%.0f%%",player.tempo*100)).monospacedDigit().frame(width:40) + Text("Rating") + Picker("Singer rating",selection:$personalRating) { ForEach(0..<6) { Text($0 == 0 ? "Unrated" : String(repeating:"★",count:$0)).tag($0) } }.labelsHidden().frame(width:88).help("Singer-specific song rating") + Button("Save for Singer") { + if let song = player.currentSongID ?? selectedSong { model.savePreference(song:song,value:.init(pitch:player.pitch,tempo:player.tempo,rating:personalRating)) } + }.disabled(model.selectedSinger == nil || (player.currentSongID == nil && selectedSong == nil)) + Menu { + Button("Reset key / tempo") { player.pitch = 0; player.tempo = 1 } + ForEach([-1.0,-0.5,0,0.5,1.0],id:\.self) { offset in Button(String(format:"Graphics offset %+.1fs",offset)) { player.graphicsOffset = offset } } + } label: { Image(systemName:"slider.horizontal.3") }.menuStyle(.borderlessButton).frame(width:22).help("Playback settings") + }.font(.system(size:11)) + .onChange(of:model.selectedSinger) { _ in loadRating() } + .onChange(of:player.currentSongID) { _ in loadRating() } + .onChange(of:selectedSong) { _ in loadRating() } + } + private func loadRating() { if let song = player.currentSongID ?? selectedSong { personalRating = model.preference(song).rating } else { personalRating = 0 } } +} +private struct SeekBar: View { + @ObservedObject var player: Player + private func clock(_ seconds: Double) -> String { let n = Int(seconds); return String(format: "%d:%02d", n / 60, n % 60) } + var body: some View { + HStack(spacing: 10) { + Text(clock(player.time)) + Slider(value: Binding(get: { player.time }, set: { player.seek($0) }), in: 0...max(1, player.duration)) + .controlSize(.mini).tint(.gray).disabled(player.duration == 0).accessibilityLabel("Playback position") + Text(clock(player.duration)) + }.font(.system(size: 10, design: .monospaced)).foregroundStyle(.white.opacity(0.65)) + } +} +private struct RepeatButton: View { + @ObservedObject var player: Player + var body: some View { + Button { player.repeating.toggle() } label: { + Image(systemName: "repeat").font(.system(size: 18, weight: .semibold)) + .foregroundStyle(player.repeating ? Color.blue : Color(white: 0.25)).frame(width: 32, height: 24) + }.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off") + } +} diff --git a/backups/before-mixer-repertoire-20261103-084301/KJamsClone/Library.swift b/backups/before-mixer-repertoire-20261103-084301/KJamsClone/Library.swift new file mode 100644 index 0000000..b98286f --- /dev/null +++ b/backups/before-mixer-repertoire-20261103-084301/KJamsClone/Library.swift @@ -0,0 +1,196 @@ +import Foundation +import SQLite3 + +struct Song: Identifiable, Hashable { + var id: Int64 + var title: String + var audio: String + var cdg: String? + var rating: Int + var plays: Int + var artist: String = "" + var album: String = "" + var label: String = "" +} +struct PersonPlace: Identifiable, Hashable { var id: Int64; var name: String } +struct SongPreference: Equatable { + var pitch: Double = 0 + var tempo: Double = 1 + var rating: Int = 0 +} +struct Performance: Identifiable { + var id: Int64 + var song: String + var singer: String + var venue: String + var date: String +} +struct QueueEntry: Identifiable { + var id: Int64 + var songID: Int64 + var singerID: Int64? + var title: String + var singer: String +} +enum LibraryError: LocalizedError { + case message(String) + var errorDescription: String? { if case .message(let s) = self { return s }; return nil } +} +final class Library { + private var db: OpaquePointer? + init(url: URL) throws { + guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw LibraryError.message("Cannot open library") } + try execute("PRAGMA foreign_keys=ON") + try execute("CREATE TABLE IF NOT EXISTS songs(id INTEGER PRIMARY KEY,title TEXT NOT NULL,audio TEXT UNIQUE NOT NULL,cdg TEXT,rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),plays INTEGER NOT NULL DEFAULT 0)") + try execute("CREATE TABLE IF NOT EXISTS singers(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS venues(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS history(id INTEGER PRIMARY KEY,song INTEGER REFERENCES songs(id),singer INTEGER REFERENCES singers(id),venue INTEGER REFERENCES venues(id),played TEXT DEFAULT CURRENT_TIMESTAMP)") + // Additive migration: preserve older libraries and infer existing memberships from history. + for column in ["artist", "album", "label"] { + if try !columns("songs").contains(column) { try execute("ALTER TABLE songs ADD COLUMN \(column) TEXT NOT NULL DEFAULT ''") } + } + try execute("CREATE TABLE IF NOT EXISTS venue_singers(venue INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,PRIMARY KEY(venue,singer))") + if try schemaVersion() < 3 { try execute("INSERT OR IGNORE INTO venue_singers SELECT DISTINCT venue,singer FROM history WHERE venue IS NOT NULL AND singer IS NOT NULL") } + try execute("CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT)") + try execute("CREATE TABLE IF NOT EXISTS preferences(song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5),rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),PRIMARY KEY(song,singer))") + try execute("CREATE TABLE IF NOT EXISTS queue(id INTEGER PRIMARY KEY,venue INTEGER REFERENCES venues(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER REFERENCES singers(id) ON DELETE CASCADE,position INTEGER NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS playlists(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS playlist_songs(playlist INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(playlist,song))") + try execute("PRAGMA user_version=3") + } + deinit { sqlite3_close(db) } + private func statement(_ sql: String, _ values: [String?]) throws -> OpaquePointer { + var s: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &s, nil) == SQLITE_OK, let s else { throw error() } + for (i,v) in values.enumerated() { + let code = v.map { sqlite3_bind_text(s, Int32(i + 1), $0, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) } ?? sqlite3_bind_null(s, Int32(i + 1)) + if code != SQLITE_OK { sqlite3_finalize(s); throw error() } + } + return s + } + private func error() -> LibraryError { .message(String(cString: sqlite3_errmsg(db))) } + func execute(_ sql: String, _ values: [String?] = []) throws { + let s = try statement(sql, values); defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_DONE else { throw error() } + } + func songs() throws -> [Song] { + let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label FROM songs ORDER BY title COLLATE NOCASE", []); defer { sqlite3_finalize(s) } + var result = [Song]() + while true { + let code = sqlite3_step(s); if code == SQLITE_DONE { break }; guard code == SQLITE_ROW else { throw error() } + result.append(Song(id: sqlite3_column_int64(s,0), title: String(cString: sqlite3_column_text(s,1)), audio: String(cString: sqlite3_column_text(s,2)), cdg: sqlite3_column_text(s,3).map { String(cString:$0) }, rating: Int(sqlite3_column_int(s,4)), plays: Int(sqlite3_column_int(s,5)), artist: text(s,6), album: text(s,7), label: text(s,8))) + } + return result + } + func add(title: String, audio: String, cdg: String?, artist: String = "", album: String = "", label: String = "") throws { + try execute("INSERT INTO songs(title,audio,cdg,artist,album,label) VALUES(?,?,?,?,?,?) ON CONFLICT(audio) DO UPDATE SET cdg=COALESCE(excluded.cdg,songs.cdg),artist=CASE WHEN excluded.artist!='' THEN excluded.artist ELSE songs.artist END,album=CASE WHEN excluded.album!='' THEN excluded.album ELSE songs.album END,label=CASE WHEN excluded.label!='' THEN excluded.label ELSE songs.label END", [title,audio,cdg,artist,album,label]) + } + func rate(_ id: Int64, _ rating: Int) throws { try execute("UPDATE songs SET rating=? WHERE id=?", [String(rating),String(id)]) } + func entities(_ table: String) throws -> [PersonPlace] { + precondition(["singers","venues","playlists"].contains(table)) + let s = try statement("SELECT id,name FROM \(table) ORDER BY name", []); defer { sqlite3_finalize(s) } + var rows = [PersonPlace]() + while sqlite3_step(s) == SQLITE_ROW { rows.append(.init(id: sqlite3_column_int64(s,0), name: String(cString: sqlite3_column_text(s,1)))) } + return rows + } + func addEntity(_ table: String, name: String) throws { + precondition(["singers","venues","playlists"].contains(table)) + let name = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { throw LibraryError.message("Enter a name") } + try execute("INSERT OR IGNORE INTO \(table)(name) VALUES(?)", [name]) + } + func record(_ song: Int64, singer: Int64?, venue: Int64?) throws { + try execute("BEGIN IMMEDIATE") + do { + try execute("INSERT INTO history(song,singer,venue) VALUES(?,?,?)", [String(song),singer.map(String.init),venue.map(String.init)]) + try execute("UPDATE songs SET plays=plays+1 WHERE id=?", [String(song)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK"); throw error } + } + + private func text(_ s: OpaquePointer, _ index: Int32) -> String { sqlite3_column_text(s,index).map { String(cString:$0) } ?? "" } + private func schemaVersion() throws -> Int { + let s = try statement("PRAGMA user_version",[]); defer { sqlite3_finalize(s) } + return sqlite3_step(s) == SQLITE_ROW ? Int(sqlite3_column_int(s,0)) : 0 + } + private func columns(_ table: String) throws -> Set { + let s = try statement("PRAGMA table_info(\(table))", []); defer { sqlite3_finalize(s) } + var result = Set(); while sqlite3_step(s) == SQLITE_ROW { result.insert(text(s,1)) }; return result + } + func members(of venue: Int64) throws -> [PersonPlace] { + let s = try statement("SELECT singers.id,singers.name FROM singers JOIN venue_singers ON singers.id=venue_singers.singer WHERE venue=? ORDER BY name", [String(venue)]); defer { sqlite3_finalize(s) } + var result = [PersonPlace](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1))) }; return result + } + func setMembership(singer: Int64, venue: Int64, included: Bool) throws { + try execute(included ? "INSERT OR IGNORE INTO venue_singers(venue,singer) VALUES(?,?)" : "DELETE FROM venue_singers WHERE venue=? AND singer=?", [String(venue),String(singer)]) + if !included { try execute("DELETE FROM queue WHERE venue=? AND singer=?",[String(venue),String(singer)]) } + } + func currentVenue() throws -> Int64? { + let s = try statement("SELECT venues.id FROM venues JOIN settings ON CAST(settings.value AS INTEGER)=venues.id WHERE settings.key='currentVenue'", []); defer { sqlite3_finalize(s) } + return sqlite3_step(s) == SQLITE_ROW ? sqlite3_column_int64(s,0) : nil + } + func selectVenue(_ id: Int64?) throws { + if let id { + guard try entities("venues").contains(where: { $0.id == id }) else { throw LibraryError.message("Venue no longer exists") } + } + try execute("INSERT INTO settings(key,value) VALUES('currentVenue',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",[id.map(String.init)]) + } + func rename(_ table: String, id: Int64, name: String) throws { + precondition(["singers","venues","playlists"].contains(table)) + let name = name.trimmingCharacters(in:.whitespacesAndNewlines) + guard !name.isEmpty else { throw LibraryError.message("Enter a name") } + try execute("UPDATE \(table) SET name=? WHERE id=?",[name,String(id)]) + } + func deleteEntity(_ table: String, id: Int64) throws { + precondition(["singers","venues","playlists"].contains(table)) + try execute("BEGIN IMMEDIATE") + do { + if table == "singers" { try execute("UPDATE history SET singer=NULL WHERE singer=?",[String(id)]) } + if table == "venues" { + try execute("UPDATE history SET venue=NULL WHERE venue=?",[String(id)]) + try execute("DELETE FROM settings WHERE key='currentVenue' AND value=?",[String(id)]) + } + try execute("DELETE FROM \(table) WHERE id=?",[String(id)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK"); throw error } + } + func preference(song: Int64, singer: Int64) throws -> SongPreference { + let s = try statement("SELECT pitch,tempo,rating FROM preferences WHERE song=? AND singer=?",[String(song),String(singer)]); defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_ROW else { return .init() } + return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:Int(sqlite3_column_int(s,2))) + } + func savePreference(song: Int64, singer: Int64, value: SongPreference) throws { + try execute("INSERT INTO preferences(song,singer,pitch,tempo,rating) VALUES(?,?,?,?,?) ON CONFLICT(song,singer) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo,rating=excluded.rating",[String(song),String(singer),String(value.pitch),String(value.tempo),String(value.rating)]) + } + func history(venue: Int64?, singer: Int64? = nil) throws -> [Performance] { + let s = try statement("SELECT history.id,COALESCE(songs.title,'Deleted song'),COALESCE(singers.name,'—'),COALESCE(venues.name,'—'),played FROM history LEFT JOIN songs ON songs.id=history.song LEFT JOIN singers ON singers.id=history.singer LEFT JOIN venues ON venues.id=history.venue WHERE (? IS NULL OR history.venue=?) AND (? IS NULL OR history.singer=?) ORDER BY history.id DESC",[venue.map(String.init),venue.map(String.init),singer.map(String.init),singer.map(String.init)]); defer { sqlite3_finalize(s) } + var result = [Performance](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),song:text(s,1),singer:text(s,2),venue:text(s,3),date:text(s,4))) }; return result + } + func enqueue(song: Int64, singer: Int64?, venue: Int64?) throws { + guard let venue else { throw LibraryError.message("Select a current venue before adding to its queue") } + if let singer, try !members(of:venue).contains(where: { $0.id == singer }) { throw LibraryError.message("Add this singer to the current venue first") } + try execute("INSERT INTO queue(venue,song,singer,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?))",[String(venue),String(song),singer.map(String.init),String(venue)]) + } + func queue(venue: Int64?) throws -> [QueueEntry] { + guard let venue else { return [] } + let s = try statement("SELECT queue.id,queue.song,queue.singer,songs.title,COALESCE(singers.name,'No singer') FROM queue JOIN songs ON songs.id=queue.song LEFT JOIN singers ON singers.id=queue.singer WHERE queue.venue=? ORDER BY queue.position,queue.id",[String(venue)]); defer { sqlite3_finalize(s) } + var result = [QueueEntry](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),songID:sqlite3_column_int64(s,1),singerID:sqlite3_column_type(s,2) == SQLITE_NULL ? nil : sqlite3_column_int64(s,2),title:text(s,3),singer:text(s,4))) }; return result + } + func removeQueue(_ id: Int64) throws { try execute("DELETE FROM queue WHERE id=?",[String(id)]) } + func moveQueue(_ id: Int64, venue: Int64, by offset: Int) throws { + var rows = try queue(venue:venue) + guard let index = rows.firstIndex(where: { $0.id == id }), rows.indices.contains(index+offset) else { return } + rows.swapAt(index,index+offset) + try execute("BEGIN IMMEDIATE") + do { for (i,row) in rows.enumerated() { try execute("UPDATE queue SET position=? WHERE id=?",[String(i),String(row.id)]) }; try execute("COMMIT") } + catch { try? execute("ROLLBACK"); throw error } + } + func addToPlaylist(_ playlist: Int64, song: Int64) throws { + try execute("INSERT OR IGNORE INTO playlist_songs(playlist,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM playlist_songs WHERE playlist=?))",[String(playlist),String(song),String(playlist)]) + } + func playlistSongIDs(_ playlist: Int64) throws -> [Int64] { + let s = try statement("SELECT song FROM playlist_songs WHERE playlist=? ORDER BY position",[String(playlist)]); defer { sqlite3_finalize(s) } + var ids = [Int64](); while sqlite3_step(s) == SQLITE_ROW { ids.append(sqlite3_column_int64(s,0)) }; return ids + } + func removeFromPlaylist(_ playlist: Int64, song: Int64) throws { try execute("DELETE FROM playlist_songs WHERE playlist=? AND song=?",[String(playlist),String(song)]) } +} diff --git a/backups/before-mixer-repertoire-20261103-084301/KJamsClone/Player.swift b/backups/before-mixer-repertoire-20261103-084301/KJamsClone/Player.swift new file mode 100644 index 0000000..5c2440b --- /dev/null +++ b/backups/before-mixer-repertoire-20261103-084301/KJamsClone/Player.swift @@ -0,0 +1,150 @@ +import SwiftUI +import AVFoundation + +/// CDG follows consumed source frames, so tempo adjustment preserves graphics timing. +@MainActor final class Player: ObservableObject { + @Published var image: CGImage? + @Published private(set) var waveform = [Float]() + @Published private(set) var waveformLoading = false + @Published private(set) var waveformUnavailable = false + private var waveformTask: Task? + private var waveformGeneration = UUID() + @Published var playing = false + @Published var time: Double = 0 + @Published var duration: Double = 0 + @Published var title = "Select a song" + @Published var volume: Double = 0.8 { didSet { engine.mainMixerNode.outputVolume = Float(volume) } } + @Published var repeating = false + @Published var pitch: Double = 0 { didSet { effect.pitch = Float(pitch * 100) } } + @Published var tempo: Double = 1 { didSet { effect.rate = Float(tempo) } } + @Published var graphicsOffset: Double = 0 + private(set) var currentSongID: Int64? + var onFinished: (() -> Void)? + private let engine = AVAudioEngine() + private let node = AVAudioPlayerNode() + private let effect = AVAudioUnitTimePitch() + private var file: AVAudioFile? + private var anchor: Double = 0 + private var generation = UUID() + private var cdg = Data() + private var decoder = CDGDecoder() + private var timer: Timer? + init() { engine.attach(node); engine.attach(effect) } + func load(_ song: Song, preference: SongPreference = .init()) throws { + stop() + do { + let loaded = try AVAudioFile(forReading:URL(fileURLWithPath:song.audio)) + let graphics = try song.cdg.map { try Data(contentsOf:URL(fileURLWithPath:$0)) } ?? Data() + guard loaded.length > 0 else { throw LibraryError.message("Audio file is empty") } + engine.connect(node,to:effect,format:loaded.processingFormat) + engine.connect(effect,to:engine.mainMixerNode,format:loaded.processingFormat) + file = loaded; cdg = graphics; decoder = CDGDecoder() + duration = Double(loaded.length)/loaded.processingFormat.sampleRate + title = song.title; currentSongID = song.id + pitch = preference.pitch; tempo = preference.tempo + engine.mainMixerNode.outputVolume = Float(volume) + engine.prepare(); try engine.start() + schedule(at:0,play:true); startTimer(); tick() + analyseWaveform(URL(fileURLWithPath:song.audio)) + } catch { stop(); throw error } + } + private func analyseWaveform(_ url: URL) { + let token = UUID(); waveformGeneration = token; waveformLoading = true + waveformTask = Task.detached(priority: .utility) { [weak self] in + let result: Result<[Float], Error> + do { result = .success(try AudioWaveform.read(url: url, cancelled: { Task.isCancelled })) } + catch { result = .failure(error) } + guard !Task.isCancelled else { return } + await MainActor.run { [weak self] in + guard let self, self.waveformGeneration == token else { return } + self.waveformLoading = false + switch result { + case .success(let samples): self.waveform = samples + case .failure: self.waveformUnavailable = true + } + } + } + } + private func schedule(at seconds: Double, play: Bool) { + guard let file else { return } + generation = UUID(); let token = generation + node.stop() + let rate = file.processingFormat.sampleRate + let start = min(file.length,max(0,AVAudioFramePosition(seconds*rate))) + anchor = Double(start)/rate; time = anchor + guard start < file.length else { playing = false; render(); return } + node.scheduleSegment(file,startingFrame:start,frameCount:AVAudioFrameCount(min(file.length-start,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack) { [weak self] _ in + Task { @MainActor in + guard let self, self.generation == token else { return } + if self.repeating { self.schedule(at:0,play:true) } + else { + self.playing = false; self.time = self.duration; self.render() + self.timer?.invalidate(); self.timer = nil; self.onFinished?() + } + } + } + if play { node.play() }; playing = play; render() + } + private func startTimer() { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval:1.0/30,repeats:true) { [weak self] _ in Task { @MainActor in self?.tick() } } + } + func toggle() { + guard file != nil else { return } + if playing { tick(); node.pause(); playing = false } + else if time >= duration { schedule(at:0,play:true); startTimer() } + else { node.play(); playing = true; startTimer() } + } + func stop() { + waveformTask?.cancel(); waveformTask = nil; waveformGeneration = UUID() + waveform = []; waveformLoading = false; waveformUnavailable = false + generation = UUID(); timer?.invalidate(); timer = nil; node.stop(); engine.stop() + engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect) + file = nil; playing = false; time = 0; anchor = 0; duration = 0; image = nil; currentSongID = nil; title = "Select a song" + } + func seek(_ value: Double) { + guard file != nil, value.isFinite else { return } + schedule(at:min(max(value,0),duration),play:playing); startTimer() + } + private func render() { decoder.render(cdg,at:max(0,time+graphicsOffset)); image = cdg.isEmpty ? nil : decoder.image() } + private func tick() { + if playing, let file, let renderTime = node.lastRenderTime, let playerTime = node.playerTime(forNodeTime:renderTime) { + time = min(duration,max(0,anchor+Double(playerTime.sampleTime)/file.processingFormat.sampleRate)) + } + render() + } +} + +/// Streams fixed-size PCM chunks into a bounded RMS envelope, independent of playback. +/// A separate AVAudioFile keeps analysis away from the player's scheduling/read position. +enum AudioWaveform { + static func read(url: URL, bins: Int = 1024, cancelled: () -> Bool = { false }) throws -> [Float] { + guard bins > 0 else { return [] } + let file = try AVAudioFile(forReading: url, commonFormat: .pcmFormatFloat32, interleaved: false) + guard file.length > 0, let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: 16384) else { return [] } + var energy = [Double](repeating: 0, count: bins) + var counts = [Int](repeating: 0, count: bins) + var position: Int64 = 0 + let channels = Int(file.processingFormat.channelCount) + while position < file.length { + if cancelled() { throw CancellationError() } + try file.read(into: buffer, frameCount: AVAudioFrameCount(min(16384, file.length-position))) + let frames = Int(buffer.frameLength) + guard frames > 0, let data = buffer.floatChannelData else { break } + for frame in 0.. 0 ? sqrt($0/Double($1)) : 0 } + let peak = rms.max() ?? 0 + guard peak > 0 else { return [Float](repeating:0,count:bins) } + return rms.map { Float(pow(min(1,$0/peak),0.7)) } + } +} diff --git a/backups/before-mixer-repertoire-20261103-084301/KJamsCloneTests/KJamsCloneTests.swift b/backups/before-mixer-repertoire-20261103-084301/KJamsCloneTests/KJamsCloneTests.swift new file mode 100644 index 0000000..61af9e3 --- /dev/null +++ b/backups/before-mixer-repertoire-20261103-084301/KJamsCloneTests/KJamsCloneTests.swift @@ -0,0 +1,266 @@ +import XCTest +import AVFoundation +@testable import KJamsClone + +final class KJamsCloneTests: XCTestCase { + func packet(_ instruction: UInt8, _ data: [UInt8]) -> [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] } + func testPresetTileXORAndInvalidCoordinates() { + var d = CDGDecoder() + d.apply(packet(1,[3,0])); XCTAssertEqual(d.pixels[0],3) + d.apply(packet(6,[0,5,1,1] + Array(repeating:63,count:12))) + XCTAssertEqual(d.pixels[12*300+6],5) + d.apply(packet(38,[0,5,1,1] + Array(repeating:63,count:12))) + XCTAssertEqual(d.pixels[12*300+6],0) + let old = d.pixels; d.apply(packet(6,[1,2,31,63])); XCTAssertEqual(d.pixels,old) + } + func testSeekAndTruncatedPacket() { + var d = CDGDecoder() + let data = Data(packet(1,[2,0]) + packet(1,[7,0]) + [9]) + d.render(data,at:1); XCTAssertEqual(d.pixels[0],7); XCTAssertEqual(d.packetIndex,2) + d.render(data,at:1.0/300); XCTAssertEqual(d.pixels[0],2) + d.render(data,at:0); XCTAssertEqual(d.pixels[0],0) + } + func testPaletteBorderScrollAndImage() { + var d = CDGDecoder() + d.apply(packet(30,[63,63])); XCTAssertEqual(d.palette[0],0xFFFFFFFF) + d.apply(packet(2,[4])); XCTAssertEqual(d.pixels[0],4); XCTAssertEqual(d.pixels[12*300+6],0) + d.apply(packet(20,[3,16,0])); XCTAssertEqual(d.pixels[100*300],3) + d.apply(packet(24,[0,32,0])); XCTAssertEqual(d.pixels[100*300+294],3) + XCTAssertEqual(d.image()?.width,288) + } + func testSQLitePersistenceDedupRatingsAndHistory() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let url = folder.appendingPathComponent("test.sqlite") + do { + let db = try Library(url:url) + try db.add(title:"Singer's Song",audio:"/test.mp3",cdg:nil) + try db.add(title:"Duplicate",audio:"/test.mp3",cdg:"/test.cdg") + let song = try XCTUnwrap(db.songs().first) + try db.rate(song.id,5); XCTAssertThrowsError(try db.rate(song.id,6)) + try db.addEntity("singers",name:"O'Neil"); try db.addEntity("venues",name:"Hall") + try db.record(song.id,singer:db.entities("singers").first?.id,venue:db.entities("venues").first?.id) + } + let reopened = try Library(url:url); let songs = try reopened.songs() + XCTAssertEqual(songs.count,1); XCTAssertEqual(songs[0].rating,5); XCTAssertEqual(songs[0].plays,1); XCTAssertEqual(songs[0].cdg,"/test.cdg") + XCTAssertEqual(try reopened.entities("singers").first?.name,"O'Neil") + } + func testPairedImportAndContentDedup() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let audio = root.appendingPathComponent("Test.mp3"), cdg = root.appendingPathComponent("Test.CDG") + try Data([1,2,3]).write(to:audio); try Data(packet(1,[2,0])).write(to:cdg) + let db = try Library(url:root.appendingPathComponent("test.sqlite")) + let importer = Importer(media:root.appendingPathComponent("Media")) + try importer.run([audio],library:db); try importer.run([audio],library:db) + XCTAssertEqual(try db.songs().count,1) + XCTAssertNotNil(try db.songs().first?.cdg) + } + + func testZippedMP3GImportAndUnsafeArchiveRejection() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let db = try Library(url:folder.appendingPathComponent("db.sqlite")) + let importer = Importer(media:folder.appendingPathComponent("Media")) + try importer.run([fixtures.appendingPathComponent("Demo.zip")],library:db) + let song = try XCTUnwrap(db.songs().first) + XCTAssertEqual(song.title,"Demo"); XCTAssertNotNil(song.cdg) + XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio)) + XCTAssertThrowsError(try importer.run([fixtures.appendingPathComponent("Unsafe.zip")],library:db)) + } + @MainActor func testMP3PlaybackPauseAndSeek() throws { + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let player = Player() + defer { player.stop() } + try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0)) + XCTAssertGreaterThan(player.duration,3.9); XCTAssertTrue(player.playing) + player.toggle(); XCTAssertFalse(player.playing) + player.seek(2); XCTAssertEqual(player.time,2,accuracy:0.05); XCTAssertNotNil(player.image) + player.seek(0.1); XCTAssertEqual(player.time,0.1,accuracy:0.05) + } + @MainActor func testCreateSingerAndVenueThroughAppModel() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let model = AppModel(storageURL: root) + XCTAssertNil(model.error) + let singer = try model.createEntity(.singer, name: " Alex ") + let venue = try model.createEntity(.venue, name: "Town Hall") + XCTAssertEqual(model.singers.first?.name, "Alex") + XCTAssertEqual(model.venues.first?.name, "Town Hall") + XCTAssertEqual(try model.createEntity(.singer, name: "Alex"), singer) + XCTAssertEqual(model.singers.count, 1) + XCTAssertThrowsError(try model.createEntity(.venue, name: " ")) + let reopened = AppModel(storageURL: root) + XCTAssertEqual(reopened.singers.first?.id, singer) + XCTAssertEqual(reopened.venues.first?.id, venue) + } + + @MainActor func testVenuesShareSingersAndRememberSelection() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root) + let hall = try model.createEntity(.venue,name:"Hall") + let alex = try model.createEntity(.singer,name:"Alex") + let club = try model.createEntity(.venue,name:"Club") + XCTAssertTrue(model.venueSingers.isEmpty) + model.membership(singer:alex,included:true) + XCTAssertEqual(model.venueSingers.map(\.id),[alex]) + model.chooseVenue(hall); XCTAssertEqual(model.venueSingers.map(\.id),[alex]) + model.chooseVenue(club); model.membership(singer:alex,included:false) + XCTAssertTrue(model.venueSingers.isEmpty) + let reopened = AppModel(storageURL:root) + XCTAssertEqual(reopened.currentVenue,club); XCTAssertTrue(reopened.venueSingers.isEmpty) + reopened.chooseVenue(hall); XCTAssertEqual(reopened.venueSingers.map(\.id),[alex]) + XCTAssertEqual(reopened.singers.count,1) + } + func testMigrationAndMembershipRemovalDoesNotReappear() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let url = root.appendingPathComponent("db.sqlite") + var singer: Int64 = 0, venue: Int64 = 0 + do { + let db = try Library(url:url) + try db.add(title:"Song",audio:"/a.mp3",cdg:nil) + try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall") + singer = try XCTUnwrap(db.entities("singers").first?.id); venue = try XCTUnwrap(db.entities("venues").first?.id) + try db.record(XCTUnwrap(db.songs().first?.id),singer:singer,venue:venue) + try db.execute("PRAGMA user_version=1") + } + do { + let migrated = try Library(url:url) + XCTAssertEqual(try migrated.members(of:venue).map(\.id),[singer]) + try migrated.setMembership(singer:singer,venue:venue,included:false) + } + let reopened = try Library(url:url) + XCTAssertTrue(try reopened.members(of:venue).isEmpty) + XCTAssertEqual(try reopened.history(venue:venue).count,1) + } + func testQueuePlaylistsPreferencesAndDeletion() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let db = try Library(url:root.appendingPathComponent("db.sqlite")) + try db.add(title:"First",audio:"/1.mp3",cdg:nil) + try db.add(title:"Second",audio:"/2.mp3",cdg:nil) + let songs = try db.songs() + try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall"); try db.addEntity("venues",name:"Club") + let singer = try XCTUnwrap(db.entities("singers").first?.id) + let hall = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Hall" })?.id) + let club = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Club" })?.id) + XCTAssertThrowsError(try db.enqueue(song:songs[0].id,singer:singer,venue:hall)) + try db.setMembership(singer:singer,venue:hall,included:true) + try db.enqueue(song:songs[0].id,singer:singer,venue:hall); try db.enqueue(song:songs[1].id,singer:singer,venue:hall) + XCTAssertTrue(try db.queue(venue:club).isEmpty) + let second = try XCTUnwrap(db.queue(venue:hall).last) + try db.moveQueue(second.id,venue:hall,by:-1) + XCTAssertEqual(try db.queue(venue:hall).first?.songID,songs[1].id) + let preference = SongPreference(pitch:3,tempo:0.85,rating:4) + try db.savePreference(song:songs[0].id,singer:singer,value:preference) + XCTAssertEqual(try db.preference(song:songs[0].id,singer:singer),preference) + XCTAssertThrowsError(try db.savePreference(song:songs[0].id,singer:singer,value:.init(pitch:99))) + try db.addEntity("playlists",name:"Party") + let playlist = try XCTUnwrap(db.entities("playlists").first?.id) + try db.addToPlaylist(playlist,song:songs[0].id); try db.addToPlaylist(playlist,song:songs[0].id) + XCTAssertEqual(try db.playlistSongIDs(playlist),[songs[0].id]) + try db.record(songs[0].id,singer:singer,venue:hall) + try db.rename("singers",id:singer,name:"Alex Jones") + XCTAssertEqual(try db.history(venue:hall).first?.singer,"Alex Jones") + try db.deleteEntity("singers",id:singer) + XCTAssertTrue(try db.queue(venue:hall).isEmpty) + XCTAssertEqual(try db.history(venue:hall).count,1) + try db.selectVenue(hall); try db.deleteEntity("venues",id:hall) + XCTAssertNil(try db.currentVenue()); XCTAssertEqual(try db.history(venue:nil).count,1) + } + @MainActor func testTempoSourceClockPauseAndResume() async throws { + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let player = Player(); defer { player.stop() } + let song = Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0) + try player.load(song,preference:.init(pitch:2,tempo:1.5,rating:3)) + XCTAssertEqual(player.pitch,2); XCTAssertEqual(player.tempo,1.5) + try await Task.sleep(nanoseconds:500_000_000) + XCTAssertGreaterThan(player.time,0.45); XCTAssertLessThan(player.time,1.3) + player.toggle(); let paused = player.time + try await Task.sleep(nanoseconds:100_000_000) + XCTAssertEqual(player.time,paused,accuracy:0.025) + player.seek(1.5); player.toggle() + try await Task.sleep(nanoseconds:200_000_000) + XCTAssertGreaterThan(player.time,1.6); XCTAssertNotNil(player.image) + } + func testNewTestMediaMetadataAndCDGPairImport() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let db = try Library(url:root.appendingPathComponent("db.sqlite")) + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let importer = Importer(media:root.appendingPathComponent("Media")) + try importer.run([fixtures.appendingPathComponent("TestTrack.cdg")],library:db) + let song = try XCTUnwrap(db.songs().first) + XCTAssertEqual(song.title,"MP3 Plus G Test") + XCTAssertEqual(song.artist,"KJamsClone Test Generator") + XCTAssertNotNil(song.cdg) + let bytes = try Data(contentsOf:fixtures.appendingPathComponent("TestTrack.cdg")) + XCTAssertEqual(bytes.count,20*300*24) + var decoder = CDGDecoder(); decoder.render(bytes,at:2) + XCTAssertTrue(decoder.pixels.contains(1)); XCTAssertTrue(decoder.pixels.contains(2)) + } + @MainActor func testAutomaticVenueQueueAdvancesAndRecordsSinger() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root); defer { model.player.stop() } + _ = try model.createEntity(.venue,name:"Test Hall") + _ = try model.createEntity(.singer,name:"Alex") + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + model.importFiles([fixtures.appendingPathComponent("Demo.zip")]) + let song = try XCTUnwrap(model.songs.first) + model.enqueue(song.id); model.enqueue(song.id); model.playQueueNext() + XCTAssertEqual(model.queue.count,1); XCTAssertEqual(model.history.count,1) + model.player.seek(model.player.duration-0.1) + for _ in 0..<25 { + if model.queue.isEmpty { break } + try await Task.sleep(nanoseconds:100_000_000) + } + XCTAssertNil(model.error); XCTAssertTrue(model.queue.isEmpty) + XCTAssertEqual(model.history.count,2); XCTAssertEqual(model.history.first?.singer,"Alex") + } + func testWaveformRepresentsSilenceAndSignalInTimeOrder() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let url = folder.appendingPathComponent("signal.wav") + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:8000,channels:1)) + do { + let file = try AVAudioFile(forWriting:url,settings:format.settings) + let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:4096)); buffer.frameLength = 4096 + let samples = try XCTUnwrap(buffer.floatChannelData?[0]) + for i in 0..<4096 { samples[i] = i < 2048 ? 0 : Float(sin(Double(i)*0.2)*0.5) } + try file.write(from:buffer) + } + let envelope = try AudioWaveform.read(url:url,bins:32) + XCTAssertEqual(envelope.count,32) + XCTAssertTrue(envelope.prefix(16).allSatisfy { $0 == 0 }) + XCTAssertTrue(envelope.suffix(16).allSatisfy { $0 > 0.8 && $0 <= 1 }) + XCTAssertThrowsError(try AudioWaveform.read(url:url,cancelled:{true})) + } + @MainActor func testWaveformAnalysisAndStopClearsTrackData() async throws { + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let player = Player(); defer { player.stop() } + try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:nil,rating:0,plays:0)) + player.toggle() + for _ in 0..<50 { + if !player.waveformLoading { break } + try await Task.sleep(nanoseconds:20_000_000) + } + XCTAssertEqual(player.waveform.count,1024) + XCTAssertFalse(player.waveformUnavailable) + player.stop() + XCTAssertTrue(player.waveform.isEmpty) + XCTAssertFalse(player.waveformLoading) + try await Task.sleep(nanoseconds:50_000_000) + XCTAssertTrue(player.waveform.isEmpty) + } +} diff --git a/backups/before-sidebar-monitor-20261103-081330/KJamsClone/KJamsCloneApp.swift b/backups/before-sidebar-monitor-20261103-081330/KJamsClone/KJamsCloneApp.swift new file mode 100644 index 0000000..89c3daa --- /dev/null +++ b/backups/before-sidebar-monitor-20261103-081330/KJamsClone/KJamsCloneApp.swift @@ -0,0 +1,619 @@ +import SwiftUI +import UniformTypeIdentifiers + +enum EntityKind: String, Identifiable { + case singer, venue + var id: String { rawValue } + var title: String { self == .singer ? "Singer" : "Venue" } + var table: String { self == .singer ? "singers" : "venues" } +} + +@MainActor final class AppModel: ObservableObject { + @Published var songs = [Song]() + @Published var singers = [PersonPlace]() + @Published var venues = [PersonPlace]() + @Published var venueSingers = [PersonPlace]() + @Published var playlists = [PersonPlace]() + @Published var queue = [QueueEntry]() + @Published var history = [Performance]() + @Published private(set) var currentVenue: Int64? + @Published var selectedSinger: Int64? + @Published var autoAdvance = true + @Published var error: String? + @Published var creating: EntityKind? + let player = Player() + private(set) var library: Library? + let root: URL + var currentVenueName: String { venues.first { $0.id == currentVenue }?.name ?? "No venue selected" } + init(storageURL: URL? = nil) { + root = storageURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("KJamsClone") + perform { + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + library = try Library(url:root.appendingPathComponent("Library.sqlite")); try refresh() + } + player.onFinished = { [weak self] in + guard let self, self.autoAdvance, !self.queue.isEmpty else { return } + self.playQueueNext() + } + } + func perform(_ work: () throws -> Void) { do { try work() } catch { self.error = error.localizedDescription } } + func refresh() throws { + guard let library else { throw LibraryError.message("Library unavailable") } + songs = try library.songs(); singers = try library.entities("singers"); venues = try library.entities("venues") + playlists = try library.entities("playlists"); currentVenue = try library.currentVenue() + venueSingers = try currentVenue.map { try library.members(of:$0) } ?? singers + if let selectedSinger, !venueSingers.contains(where: { $0.id == selectedSinger }) { self.selectedSinger = nil } + queue = try library.queue(venue:currentVenue); history = try library.history(venue:currentVenue) + } + func chooseVenue(_ id: Int64?) { + perform { + guard let library else { throw LibraryError.message("Library unavailable") } + try library.selectVenue(id); selectedSinger = nil; try refresh() + } + } + func membership(singer: Int64, included: Bool) { + perform { + guard let library, let currentVenue else { throw LibraryError.message("Select a current venue first") } + try library.setMembership(singer:singer,venue:currentVenue,included:included); try refresh() + } + } + @discardableResult func createEntity(_ kind: EntityKind, name: String) throws -> Int64 { + guard let library else { throw LibraryError.message("Library unavailable") } + let trimmed = name.trimmingCharacters(in:.whitespacesAndNewlines) + try library.addEntity(kind.table,name:trimmed) + guard let entity = try library.entities(kind.table).first(where: { $0.name == trimmed }) else { throw LibraryError.message("Could not load new entry") } + if kind == .singer { + if let currentVenue { try library.setMembership(singer:entity.id,venue:currentVenue,included:true) } + selectedSinger = entity.id + } else { try library.selectVenue(entity.id); selectedSinger = nil } + try refresh(); return entity.id + } + func rename(_ table: String, id: Int64, name: String) { perform { try library?.rename(table,id:id,name:name); try refresh() } } + func delete(_ table: String, id: Int64) { perform { try library?.deleteEntity(table,id:id); try refresh() } } + func preference(_ song: Int64) -> SongPreference { + guard let library, let selectedSinger else { return .init() } + return (try? library.preference(song:song,singer:selectedSinger)) ?? .init() + } + func savePreference(song: Int64, value: SongPreference) { + perform { + guard let library, let selectedSinger else { throw LibraryError.message("Select a singer to save their song preferences") } + try library.savePreference(song:song,singer:selectedSinger,value:value); try refresh() + } + } + func play(_ song: Song, singer: Int64? = nil) throws { + guard let library else { throw LibraryError.message("Library unavailable") } + let singer = singer ?? selectedSinger + if let singer, let currentVenue, try !library.members(of:currentVenue).contains(where: { $0.id == singer }) { throw LibraryError.message("This singer is not in the current venue") } + let preference = try singer.map { try library.preference(song:song.id,singer:$0) } ?? SongPreference() + try player.load(song,preference:preference) + do { try library.record(song.id,singer:singer,venue:currentVenue); selectedSinger = singer; try refresh() } + catch { player.stop(); throw error } + } + func enqueue(_ song: Int64) { perform { try library?.enqueue(song:song,singer:selectedSinger,venue:currentVenue); try refresh() } } + func playQueueNext() { + perform { + guard let row = queue.first, let song = songs.first(where: { $0.id == row.songID }) else { return } + // Explicitly clear selection for unassigned queue entries. + selectedSinger = row.singerID + try play(song,singer:row.singerID); try library?.removeQueue(row.id); try refresh() + } + } + func moveQueue(_ id: Int64, by offset: Int) { perform { if let currentVenue { try library?.moveQueue(id,venue:currentVenue,by:offset); try refresh() } } } + func removeQueue(_ id: Int64) { perform { try library?.removeQueue(id); try refresh() } } + func createPlaylist(_ name: String) { perform { try library?.addEntity("playlists",name:name); try refresh() } } + func addToPlaylist(_ playlist: Int64, song: Int64) { perform { try library?.addToPlaylist(playlist,song:song); try refresh() } } + func importFiles(_ urls: [URL]) { perform { guard let library else { throw LibraryError.message("Library unavailable") }; try Importer(media:root.appendingPathComponent("Media")).run(urls,library:library); try refresh() } } +} +@main struct KJamsCloneApp: App { + @StateObject private var model = AppModel() + var body: some Scene { + Window("KJamsClone", id: "main") { + LibraryView(model: model).frame(minWidth: 1060, minHeight: 680).preferredColorScheme(.dark) + }.defaultSize(width: 1260, height: 840) + .commands { + CommandGroup(replacing: .newItem) { + Button("New Singer…") { model.creating = .singer }.keyboardShortcut("n", modifiers: [.command, .shift]) + Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option]) + } + } + Window("Video", id: "lyrics") { + LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark) + }.defaultSize(width: 600, height: 400) + } +} +private enum Classic { + static let frame = Color(red: 0.15, green: 0.15, blue: 0.15) + static let navy = Color(red: 0.008, green: 0.065, blue: 0.16) + static let blue = Color(red: 0.015, green: 0.17, blue: 0.42) + static let header = Color(white: 0.115) + static let line = Color(white: 0.42) +} +struct LyricsView: View { + @ObservedObject var player: Player + var body: some View { + ZStack { + Color.black + if let image = player.image { + Image(decorative: image, scale: 1).interpolation(.none).resizable().aspectRatio(1.5, contentMode: .fit) + } + } + } +} +private struct SilverSquareStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label.foregroundStyle(Color(white: 0.25)).padding(.horizontal, 6).padding(.vertical, 3) + .background(LinearGradient(colors: [Color(white: 0.95), Color(white: 0.64)], startPoint: .top, endPoint: .bottom), in: RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Color(white: 0.45), lineWidth: 1.5)) + .overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(.white.opacity(0.7)).padding(3)) + .opacity(configuration.isPressed ? 0.7 : 1) + } +} +private struct SilverButton: View { + let symbol: String + let label: String + var size: CGFloat = 40 + var active = false + var enabled = true + let action: () -> Void + var body: some View { + Button(action: action) { + Image(systemName: symbol).font(.system(size: size * 0.41, weight: .semibold)) + .foregroundStyle(active ? Color.blue : Color(white: 0.24)) + .frame(width: size, height: size) + .background(LinearGradient(colors: [.white, Color(white: 0.85), Color(white: 0.63)], startPoint: .top, endPoint: .bottom), in: Circle()) + .overlay(Circle().strokeBorder(Color(white: 0.45), lineWidth: 1.5)) + .overlay(Circle().strokeBorder(.white.opacity(0.65), lineWidth: 1).padding(3)) + .shadow(color: .black.opacity(0.7), radius: 1, y: 1) + }.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.72) + .help(label).accessibilityLabel(label) + } +} +struct LibraryView: View { + @ObservedObject var model: AppModel + @State private var selected: Int64? + @State private var search = "" + @State private var name = "" + @State private var importing = false + @State private var source = "Library" + @Environment(\.openWindow) private var openWindow + private var visibleSongs: [Song] { model.songs.filter { search.isEmpty || ($0.title+" "+$0.artist+" "+$0.album).localizedCaseInsensitiveContains(search) } } + private var selectedSong: Song? { model.songs.first { $0.id == selected } } + var body: some View { + VStack(spacing: 0) { + TransportView(player: model.player, search: $search, hasSelection: selectedSong != nil, + play: playSelected, next: playNext, display: { openWindow(id: "lyrics") }) + .padding(.horizontal, 18).padding(.top, 18).padding(.bottom, 22) + HSplitView { + sidebar.frame(minWidth: 190, idealWidth: 220, maxWidth: 240) + VStack(spacing: 12) { + if source == "Library" { songTable } + else if source == "Queue" { queuePane } + else if source == "History" { historyPane } + else if source == "Playlists" { playlistPane } + else { directory } + detailPanel.frame(height: 135) + }.padding(.leading, 12).frame(minWidth: 780) + }.padding(.horizontal, 18) + footer.padding(.horizontal, 18).padding(.vertical, 12) + } + .background(Classic.frame) + .sheet(item: $model.creating) { kind in + AddEntitySheet(model: model, kind: kind) { id in + if kind == .singer { model.selectedSinger = id } else { model.chooseVenue(id) } + } + } + .fileImporter(isPresented: $importing, allowedContentTypes: [.audio, .zip, UTType(filenameExtension: "cdg") ?? .data], allowsMultipleSelection: true) { result in + switch result { + case .success(let urls): model.importFiles(urls) + case .failure(let error): model.error = error.localizedDescription + } + } + .alert("KJamsClone", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { + Button("OK") { model.error = nil } + } message: { Text(model.error ?? "") } + } + private func sourceCount(_ source: String) -> Int { + switch source { + case "Library": return model.songs.count + case "Singers": return model.venueSingers.count + case "Venues": return model.venues.count + case "Queue": return model.queue.count + case "History": return model.history.count + default: return model.playlists.count + } + } + private var sidebar: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Source").font(.system(size: 15, weight: .semibold)).padding(.horizontal, 10).frame(height: 31) + .frame(maxWidth: .infinity, alignment: .leading).background(Classic.header) + ForEach([("Library", "music.note.house.fill", Color.yellow), ("Singers", "person.2.fill", Color.green), ("Venues", "building.2.fill", Color.cyan), ("Queue", "list.number", Color.orange), ("History", "clock.arrow.circlepath", Color.purple), ("Playlists", "music.note.list", Color.mint)], id: \.0) { item in + Button { source = item.0 } label: { + HStack(spacing: 10) { + Image(systemName: item.1).foregroundStyle(item.2).frame(width: 22) + Text(item.0).font(.system(size: 16, weight: .medium)) + Spacer() + Text("\(sourceCount(item.0))") + .font(.system(size: 11)).foregroundStyle(.white.opacity(0.6)) + }.padding(.horizontal, 12).frame(height: 34).contentShape(Rectangle()) + .background(source == item.0 ? Color(red: 0, green: 0.36, blue: 1) : .clear) + }.buttonStyle(.plain) + } + Spacer() + }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) + } + private var songTable: some View { + GeometryReader { geometry in + ScrollView(.horizontal) { + VStack(spacing: 0) { + tableHeader(width: max(geometry.size.width, 790)) + ScrollView(.vertical) { + LazyVStack(spacing: 0) { + ForEach(visibleSongs) { song in songRow(song, width: max(geometry.size.width, 790)) } + }.frame(maxWidth: .infinity, alignment: .topLeading) + } + }.frame(width: max(geometry.size.width, 790), height: geometry.size.height) + } + }.background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) + } + private func widths(_ total: CGFloat) -> [CGFloat] { + [42, max(200, total - 598), 100, 100, 76, 86, 72, 122] + } + private func tableHeader(width: CGFloat) -> some View { + let sizes = widths(width) + return HStack(spacing: 0) { + ForEach(Array(["PLI", "Song Name", "Artist", "Album", "Label", "Key Adjust", "Tempo", "Rating"].enumerated()), id: \.offset) { index, title in + Text(title).font(.system(size: 13, weight: .medium)).lineLimit(1) + .padding(.leading, 7).frame(width: sizes[index], height: 30, alignment: .leading) + .overlay(alignment: .trailing) { Rectangle().fill(Classic.line).frame(width: 1).padding(.vertical, 4) } + } + }.foregroundStyle(.white).background(Classic.header) + .overlay(alignment: .bottom) { Rectangle().fill(Color(white: 0.65)).frame(height: 1) } + } + private func cellText(_ song: Song, column: Int) -> String { + switch column { + case 2: return song.artist.isEmpty ? "—" : song.artist + case 3: return song.album.isEmpty ? "—" : song.album + case 4: return song.label.isEmpty ? (song.cdg == nil ? "Audio" : URL(fileURLWithPath:song.audio).pathExtension.uppercased()+"+G") : song.label + case 5: return String(format:"%+.0f",model.preference(song.id).pitch) + default: return String(format:"%.0f%%",model.preference(song.id).tempo*100) + } + } + private func songRow(_ song: Song, width: CGFloat) -> some View { + let sizes = widths(width) + return HStack(spacing: 0) { + Image(systemName: song.cdg == nil ? "music.note" : "music.note.tv") + .foregroundStyle(.white.opacity(0.75)).frame(width: sizes[0]) + Text(song.title).lineLimit(1).padding(.leading, 7).frame(width: sizes[1], alignment: .leading) + ForEach(2..<7) { index in + Text(cellText(song, column: index)) + .foregroundStyle(.white.opacity(0.5)).padding(.leading, 7).frame(width: sizes[index], alignment: .leading) + } + Menu { + ForEach(0..<6) { value in + Button(value == 0 ? "Unrated" : String(repeating: "★", count: value)) { + model.perform { try model.library?.rate(song.id, value); try model.refresh() } + } + } + } label: { + Text(song.rating == 0 ? "☆☆☆☆☆" : String(repeating: "★", count: song.rating) + String(repeating: "☆", count: 5 - song.rating)) + .font(.system(size: 13)).foregroundStyle(song.rating == 0 ? Color.gray : Color.yellow) + }.menuStyle(.borderlessButton).frame(width: sizes[7] - 14).padding(.horizontal, 7).help("Set song rating") + }.font(.system(size: 13)).foregroundStyle(.white).frame(height: 30) + .background(selected == song.id ? Color(red: 0, green: 0.28, blue: 0.72) : .clear) + .contentShape(Rectangle()).onTapGesture(count: 2) { selected = song.id; playSelected() } + .onTapGesture { selected = song.id } + .contextMenu { + Button("Play") { selected = song.id; playSelected() } + Button("Add to Current Venue Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Menu("Add to Playlist") { ForEach(model.playlists) { playlist in Button(playlist.name) { model.addToPlaylist(playlist.id,song:song.id) } } } + } + } + @State private var managingRoster = false + @State private var rosterNewName = "" + @State private var editingEntity: PersonPlace? + @State private var renameText = "" + @State private var pendingDelete: PersonPlace? + private var directory: some View { + VStack(alignment:.leading,spacing:0) { + HStack { + Text(source == "Singers" ? "Singers · \(model.currentVenueName)" : "Venues").font(.headline) + Spacer() + if source == "Singers" { Button("Manage Venue Singers…") { managingRoster = true }.disabled(model.currentVenue == nil) } + Button(source == "Singers" ? "New Singer…" : "New Venue…") { model.creating = source == "Singers" ? .singer : .venue } + }.padding(12).background(Classic.header) + ScrollView { + VStack(alignment:.leading,spacing:0) { + if source == "Singers" && model.venueSingers.isEmpty { + Text("No singers at this venue. Add a singer or use Manage Venue Singers to include existing singers.").foregroundStyle(.secondary).padding() + } + ForEach(source == "Singers" ? model.venueSingers : model.venues) { entity in + HStack { + Button { + if source == "Singers" { model.selectedSinger = entity.id } else { model.chooseVenue(entity.id) } + } label: { + HStack { + Image(systemName:source == "Singers" ? "person.fill" : "building.2.fill") + Text(entity.name); Spacer() + if entity.id == (source == "Singers" ? model.selectedSinger : model.currentVenue) { + Text(source == "Singers" ? "Selected" : "Current venue").font(.caption).foregroundStyle(.cyan) + Image(systemName:"checkmark.circle.fill").foregroundStyle(.cyan) + } + }.contentShape(Rectangle()) + }.buttonStyle(.plain) + Button("Rename") { renameText = entity.name; editingEntity = entity } + Button("Delete",role:.destructive) { pendingDelete = entity } + }.padding(12) + } + } + } + HStack { + TextField(source == "Singers" ? "Singer name" : "Venue name",text:$name).textFieldStyle(.roundedBorder).onSubmit(addEntity) + Button(source == "Singers" ? "Add Singer" : "Add Venue",action:addEntity).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + }.padding(12) + }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + .sheet(isPresented:$managingRoster) { rosterSheet } + .sheet(item:$editingEntity) { entity in + VStack(alignment:.leading,spacing:16) { + Text("Rename \(source == "Singers" ? "Singer" : "Venue")").font(.headline) + TextField("Name",text:$renameText).textFieldStyle(.roundedBorder) + HStack { Button("Cancel") { editingEntity = nil }; Spacer(); Button("Save") { model.rename(source == "Singers" ? "singers" : "venues",id:entity.id,name:renameText); editingEntity = nil }.disabled(renameText.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) } + }.padding(24).frame(width:360) + } + .confirmationDialog("Delete \(pendingDelete?.name ?? "entry")? Historical plays are kept. Venue memberships and queued entries for it will be removed.",isPresented:Binding(get:{ pendingDelete != nil },set:{ if !$0 { pendingDelete = nil } })) { + Button("Delete",role:.destructive) { if let entity = pendingDelete { model.delete(source == "Singers" ? "singers" : "venues",id:entity.id) }; pendingDelete = nil } + } + } + private var rosterSheet: some View { + VStack(alignment:.leading,spacing:16) { + Text("Singers at \(model.currentVenueName)").font(.title2.bold()) + Text("A singer can belong to any number of venues. Unchecking removes them from this venue only.").foregroundStyle(.secondary) + ScrollView { + VStack(alignment:.leading,spacing:12) { + ForEach(model.singers) { singer in + Toggle(singer.name,isOn:Binding(get:{ model.venueSingers.contains { $0.id == singer.id } },set:{ model.membership(singer:singer.id,included:$0) })) + } + }.frame(maxWidth:.infinity,alignment:.leading) + }.frame(minHeight:120,maxHeight:280) + HStack { + TextField("New singer name",text:$rosterNewName).textFieldStyle(.roundedBorder) + Button("Add") { model.perform { _ = try model.createEntity(.singer,name:rosterNewName); rosterNewName = "" } }.disabled(rosterNewName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + Spacer(); Button("Done") { managingRoster = false }.keyboardShortcut(.defaultAction) + } + }.padding(24).frame(width:460) + } + private var detailPanel: some View { + VStack(alignment:.leading,spacing:9) { + HStack(spacing:10) { + Picker("Current venue",selection:Binding(get:{ model.currentVenue },set:{ model.chooseVenue($0) })) { + Text("Select venue…").tag(nil as Int64?) + ForEach(model.venues) { Text($0.name).tag(Optional($0.id)) } + } + Button { model.creating = .venue } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add venue") + Picker("Singer",selection:$model.selectedSinger) { + Text("None").tag(nil as Int64?) + ForEach(model.venueSingers) { Text($0.name).tag(Optional($0.id)) } + } + Button { model.creating = .singer } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add singer") + Button("Queue Song") { if let selected { model.enqueue(selected) } }.disabled(selected == nil || model.currentVenue == nil) + } + PlaybackSettings(player:model.player,model:model,selectedSong:selected) + SeekBar(player:model.player) + }.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity) + .background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + @State private var playlistName = "" + @State private var selectedPlaylist: Int64? + private var queuePane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { Text("Queue · \(model.currentVenueName)").font(.headline); Spacer(); Toggle("Auto play next",isOn:$model.autoAdvance).toggleStyle(.checkbox); Button("Play Next") { model.playQueueNext() }.disabled(model.queue.isEmpty) } + ScrollView { + VStack(spacing:0) { + ForEach(Array(model.queue.enumerated()),id:\.element.id) { index,row in + HStack { + Text("\(index+1)").foregroundStyle(.secondary).frame(width:24) + Text(row.singer).frame(width:140,alignment:.leading) + Text(row.title).lineLimit(1); Spacer() + Button { model.moveQueue(row.id,by:-1) } label: { Image(systemName:"arrow.up") }.disabled(index == 0) + Button { model.moveQueue(row.id,by:1) } label: { Image(systemName:"arrow.down") }.disabled(index == model.queue.count-1) + Button("Remove") { model.removeQueue(row.id) } + }.padding(.vertical,8) + } + if model.queue.isEmpty { Text("Choose a venue and singer, select a library song, then click Queue Song.").foregroundStyle(.secondary).padding(.top,30) } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + @State private var historyAllVenues = false + private var displayedHistory: [Performance] { + if historyAllVenues { return (try? model.library?.history(venue:nil,singer:model.selectedSinger)) ?? [] } + return model.history.filter { model.selectedSinger == nil || $0.singer == model.singers.first(where: { $0.id == model.selectedSinger })?.name } + } + private var historyPane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { Text("History · \(historyAllVenues || model.currentVenue == nil ? "All venues" : model.currentVenueName)").font(.headline); Spacer(); Toggle("All venues",isOn:$historyAllVenues).toggleStyle(.checkbox) } + ScrollView { + LazyVStack(alignment:.leading,spacing:12) { + ForEach(displayedHistory) { row in + HStack { Text(row.date).font(.system(.caption,design:.monospaced)); Text(row.singer).frame(width:130,alignment:.leading); Text(row.song); Spacer(); Text(row.venue).foregroundStyle(.secondary) } + } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + private var playlistPane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { + Picker("Playlist",selection:$selectedPlaylist) { Text("Choose…").tag(nil as Int64?); ForEach(model.playlists) { Text($0.name).tag(Optional($0.id)) } } + TextField("New playlist name",text:$playlistName).textFieldStyle(.roundedBorder) + Button("Create") { model.createPlaylist(playlistName); selectedPlaylist = model.playlists.first { $0.name == playlistName.trimmingCharacters(in:.whitespacesAndNewlines) }?.id; playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + if let selectedPlaylist { + Button("Rename") { model.rename("playlists",id:selectedPlaylist,name:playlistName); playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + Button("Delete",role:.destructive) { model.delete("playlists",id:selectedPlaylist); self.selectedPlaylist = nil } } + } + ScrollView { + if let selectedPlaylist { + let ids = (try? model.library?.playlistSongIDs(selectedPlaylist)) ?? [] + VStack(alignment:.leading,spacing:10) { + ForEach(ids,id:\.self) { id in + if let song = model.songs.first(where: { $0.id == id }) { + HStack { + Text(song.title); Spacer() + Button("Play") { model.perform { try model.play(song) } } + Button("Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Button("Remove") { model.perform { try model.library?.removeFromPlaylist(selectedPlaylist,song:id); try model.refresh() } } + } + } + } + } + } + Text("Add songs using the library row’s context menu.").foregroundStyle(.secondary).padding(.top,20) + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + private var footer: some View { + HStack { + Button { importing = true } label: { Image(systemName: "plus").font(.system(size: 20, weight: .bold)).frame(width: 32, height: 24) } + .help("Import audio, MP3+G or ZIP").accessibilityLabel("Import songs") + RepeatButton(player: model.player).padding(.leading, 12) + Spacer() + Text("\(visibleSongs.count) \(visibleSongs.count == 1 ? "Song" : "Songs")").font(.system(size: 14, weight: .medium)) + Spacer() + Button { openWindow(id: "lyrics") } label: { Image(systemName: "rectangle.on.rectangle").frame(width: 32, height: 24) } + .help("Open video window").accessibilityLabel("Open video window") + }.buttonStyle(SilverSquareStyle()) + } + private func playSelected() { + guard let song = selectedSong else { return } + model.perform { try model.play(song) } + } + private func playNext() { + guard !visibleSongs.isEmpty else { return } + let index = visibleSongs.firstIndex { $0.id == selected } ?? -1 + selected = visibleSongs[(index + 1) % visibleSongs.count].id; playSelected() + } + private func addEntity() { + model.perform { + let kind: EntityKind = source == "Singers" ? .singer : .venue + let id = try model.createEntity(kind, name: name) + if kind == .singer { model.selectedSinger = id } else { model.chooseVenue(id) } + name = "" + } + } +} +private struct AddEntitySheet: View { + @ObservedObject var model: AppModel + let kind: EntityKind + let saved: (Int64) -> Void + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var error: String? + @FocusState private var focused: Bool + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("New \(kind.title)").font(.title2.bold()) + TextField("\(kind.title) name", text: $name).textFieldStyle(.roundedBorder) + .focused($focused).onSubmit(save) + if let error { Text(error).foregroundStyle(.red).font(.callout) } + HStack { + Spacer() + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Button("Add \(kind.title)", action: save).keyboardShortcut(.defaultAction) + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + }.padding(24).frame(width: 360).onAppear { focused = true } + } + private func save() { + do { let id = try model.createEntity(kind, name: name); saved(id); dismiss() } + catch { self.error = error.localizedDescription } + } +} +private struct TransportView: View { + @ObservedObject var player: Player + @Binding var search: String + let hasSelection: Bool + let play: () -> Void + let next: () -> Void + let display: () -> Void + var body: some View { + HStack(alignment: .top, spacing: 28) { + VStack(spacing: 15) { + HStack(spacing: 7) { + SilverButton(symbol: "backward.fill", label: "Back 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time - 10) } + SilverButton(symbol: player.playing ? "pause.fill" : "play.fill", label: player.playing ? "Pause" : "Play", size: 49, enabled: hasSelection || player.duration > 0) { + if player.duration > 0 { player.toggle() } else { play() } + } + SilverButton(symbol: "stop", label: "Stop", size: 46, enabled: player.duration > 0) { player.stop() } + SilverButton(symbol: "forward.fill", label: "Forward 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time + 10) } + SilverButton(symbol: "forward.end.fill", label: "Play next song", size: 26, enabled: hasSelection, action: next) + } + HStack(spacing: 9) { + Image(systemName: "speaker.fill").font(.system(size: 11)) + Slider(value: $player.volume, in: 0...1).controlSize(.small).tint(.gray).accessibilityLabel("Volume") + Image(systemName: "speaker.wave.3.fill").font(.system(size: 12)) + }.frame(width: 180) + }.frame(width: 220) + VStack(spacing: 0) { + Text(player.duration == 0 ? "Ready!" : player.title).font(.system(size: 15, weight: .medium)).lineLimit(1).padding(.horizontal, 20) + if player.duration > 0 { Text(player.playing ? "Playing" : "Paused").font(.system(size: 10)).foregroundStyle(.white.opacity(0.65)).padding(.top, 4) } + }.frame(maxWidth: .infinity).frame(height: 64) + .background(LinearGradient(colors: [Color(red: 0, green: 0.10, blue: 0.30), Classic.blue], startPoint: .top, endPoint: .bottom), in: Capsule()) + .overlay(Capsule().strokeBorder(LinearGradient(colors: [.black, .white], startPoint: .top, endPoint: .bottom), lineWidth: 1.5)) + .padding(.top, 14) + HStack(spacing: 12) { + TextField("Search", text: $search).textFieldStyle(.plain).font(.system(size: 16)) + .padding(.horizontal, 8).frame(width: 145, height: 28).background(Color(white: 0.07), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).stroke(.white.opacity(0.1))) + SilverButton(symbol: "eye", label: "Open video window", size: 48, action: display) + }.padding(.top, 15) + }.foregroundStyle(.white).frame(height: 96) + } +} +private struct PlaybackSettings: View { + @ObservedObject var player: Player + @ObservedObject var model: AppModel + let selectedSong: Int64? + @State private var personalRating = 0 + var body: some View { + HStack(spacing:10) { + Text("Key") + Picker("Key",selection:$player.pitch) { ForEach(-12..<13) { Text(String(format:"%+d",$0)).tag(Double($0)) } }.labelsHidden().frame(width:70) + Text("Tempo") + Slider(value:$player.tempo,in:0.5...1.5,step:0.05).frame(width:85) + Text(String(format:"%.0f%%",player.tempo*100)).monospacedDigit().frame(width:40) + Text("Rating") + Picker("Singer rating",selection:$personalRating) { ForEach(0..<6) { Text($0 == 0 ? "Unrated" : String(repeating:"★",count:$0)).tag($0) } }.labelsHidden().frame(width:88).help("Singer-specific song rating") + Button("Save for Singer") { + if let song = player.currentSongID ?? selectedSong { model.savePreference(song:song,value:.init(pitch:player.pitch,tempo:player.tempo,rating:personalRating)) } + }.disabled(model.selectedSinger == nil || (player.currentSongID == nil && selectedSong == nil)) + Menu { + Button("Reset key / tempo") { player.pitch = 0; player.tempo = 1 } + ForEach([-1.0,-0.5,0,0.5,1.0],id:\.self) { offset in Button(String(format:"Graphics offset %+.1fs",offset)) { player.graphicsOffset = offset } } + } label: { Image(systemName:"slider.horizontal.3") }.menuStyle(.borderlessButton).frame(width:22).help("Playback settings") + }.font(.system(size:11)) + .onChange(of:model.selectedSinger) { _ in loadRating() } + .onChange(of:player.currentSongID) { _ in loadRating() } + .onChange(of:selectedSong) { _ in loadRating() } + } + private func loadRating() { if let song = player.currentSongID ?? selectedSong { personalRating = model.preference(song).rating } else { personalRating = 0 } } +} +private struct SeekBar: View { + @ObservedObject var player: Player + private func clock(_ seconds: Double) -> String { let n = Int(seconds); return String(format: "%d:%02d", n / 60, n % 60) } + var body: some View { + HStack(spacing: 10) { + Text(clock(player.time)) + Slider(value: Binding(get: { player.time }, set: { player.seek($0) }), in: 0...max(1, player.duration)) + .controlSize(.mini).tint(.gray).disabled(player.duration == 0).accessibilityLabel("Playback position") + Text(clock(player.duration)) + }.font(.system(size: 10, design: .monospaced)).foregroundStyle(.white.opacity(0.65)) + } +} +private struct RepeatButton: View { + @ObservedObject var player: Player + var body: some View { + Button { player.repeating.toggle() } label: { + Image(systemName: "repeat").font(.system(size: 18, weight: .semibold)) + .foregroundStyle(player.repeating ? Color.blue : Color(white: 0.25)).frame(width: 32, height: 24) + }.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off") + } +} diff --git a/backups/before-tonight-drag-20261103-094630/KJamsClone/KJamsCloneApp.swift b/backups/before-tonight-drag-20261103-094630/KJamsClone/KJamsCloneApp.swift new file mode 100644 index 0000000..67647b3 --- /dev/null +++ b/backups/before-tonight-drag-20261103-094630/KJamsClone/KJamsCloneApp.swift @@ -0,0 +1,1251 @@ +import SwiftUI +import UniformTypeIdentifiers +import AVFoundation + +enum EntityKind: String, Identifiable { + case singer, venue + var id: String { rawValue } + var title: String { self == .singer ? "Singer" : "Venue" } + var table: String { self == .singer ? "singers" : "venues" } +} + +@MainActor final class AppModel: ObservableObject { + @Published var songs = [Song]() + @Published var singers = [PersonPlace]() + @Published var singerSongIDs = [Int64:[Int64]]() + @Published var favourites = [Int64:Set]() + @Published var musicStores = [MusicStore]() + @Published var readingDurations = false + var newSingerSongs = Set() + private var songIndex = [Int64:Song]() + @Published var venues = [PersonPlace]() + @Published var venueSingers = [PersonPlace]() + @Published var playlists = [PersonPlace]() + @Published var queue = [QueueEntry]() + @Published var tonightSongIDs = [Int64:[Int64]]() + @Published var history = [Performance]() + @Published private(set) var currentVenue: Int64? + @Published var selectedSinger: Int64? + @Published var autoAdvance = true + @Published var error: String? + @Published var creating: EntityKind? + let player: Player + private(set) var library: Library? + let root: URL + var currentVenueName: String { venues.first { $0.id == currentVenue }?.name ?? "No venue selected" } + init(storageURL: URL? = nil) { + root = storageURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("KJamsClone") + player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms")) + perform { + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + library = try Library(url:root.appendingPathComponent("Library.sqlite")); try refresh() + } + player.onFinished = { [weak self] in + guard let self, self.autoAdvance, !self.queue.isEmpty else { return } + self.playQueueNext() + } + } + func perform(_ work: () throws -> Void) { do { try work() } catch { self.error = error.localizedDescription } } + func refresh() throws { + guard let library else { throw LibraryError.message("Library unavailable") } + songs = try library.songs();songIndex = Dictionary(uniqueKeysWithValues:songs.map { ($0.id,$0) }); singers = try library.entities("singers"); venues = try library.entities("venues") + singerSongIDs = try library.singerSongIDs();favourites = try library.favouriteSongIDs();musicStores = try library.stores() + playlists = try library.entities("playlists"); currentVenue = try library.currentVenue() + venueSingers = try currentVenue.map { try library.members(of:$0) } ?? singers + if let selectedSinger, !venueSingers.contains(where: { $0.id == selectedSinger }) { self.selectedSinger = nil } + tonightSongIDs = try library.tonightSongIDs(venue:currentVenue) + queue = try library.queue(venue:currentVenue); history = try library.history(venue:currentVenue) + } + func chooseVenue(_ id: Int64?) { + perform { + guard let library else { throw LibraryError.message("Library unavailable") } + try library.selectVenue(id); selectedSinger = nil; try refresh() + } + } + func membership(singer: Int64, included: Bool) { + perform { + guard let library, let currentVenue else { throw LibraryError.message("Select a current venue first") } + try library.setMembership(singer:singer,venue:currentVenue,included:included); try refresh() + } + } + @discardableResult func createEntity(_ kind: EntityKind, name: String) throws -> Int64 { + guard let library else { throw LibraryError.message("Library unavailable") } + let trimmed = name.trimmingCharacters(in:.whitespacesAndNewlines) + try library.addEntity(kind.table,name:trimmed) + guard let entity = try library.entities(kind.table).first(where: { $0.name == trimmed }) else { throw LibraryError.message("Could not load new entry") } + if kind == .singer { + if let currentVenue { try library.setMembership(singer:entity.id,venue:currentVenue,included:true) } + selectedSinger = entity.id + } else { try library.selectVenue(entity.id); selectedSinger = nil } + try refresh(); return entity.id + } + func beginSinger(song: Int64? = nil) { newSingerSongs = Set(song.map { [$0] } ?? []); creating = .singer } + func songs(for singer: Int64) -> [Song] { + (singerSongIDs[singer] ?? []).compactMap { songIndex[$0] } + } + func assign(_ song: Int64, to singer: Int64, included: Bool = true) { + perform { + guard let library else { throw LibraryError.message("Library unavailable") } + if included { try library.assignSong(song,singer:singer) } else { try library.removeSingerSong(song,singer:singer) } + try refresh() + } + } + func createSinger(name: String, venue: Int64, songs: Set) throws -> Int64 { + guard let library else { throw LibraryError.message("Library unavailable") } + guard venues.contains(where: { $0.id == venue }) else { throw LibraryError.message("Choose a venue first") } + let name = name.trimmingCharacters(in:.whitespacesAndNewlines) + try library.execute("BEGIN IMMEDIATE") + let id: Int64 + do { + try library.addEntity("singers",name:name) + guard let singer = try library.entities("singers").first(where: { $0.name == name }) else { throw LibraryError.message("Could not load singer") } + id = singer.id + try library.setMembership(singer:id,venue:venue,included:true) + for song in songs { try library.assignSong(song,singer:id) } + try library.selectVenue(venue);try library.execute("COMMIT") + } catch { try? library.execute("ROLLBACK");throw error } + try refresh();selectedSinger = id;return id + } + func rename(_ table: String, id: Int64, name: String) { perform { try library?.rename(table,id:id,name:name); try refresh() } } + func delete(_ table: String, id: Int64) { perform { try library?.deleteEntity(table,id:id); try refresh() } } + func preference(_ song: Int64) -> SongPreference { + guard let library else { return .init() } + if let selectedSinger { return (try? library.preference(song:song,singer:selectedSinger)) ?? .init() } + return (try? library.defaultPreference(song:song)) ?? .init() + } + func savePreference(song: Int64, value: SongPreference) { + perform { + guard let library, let selectedSinger else { throw LibraryError.message("Select a singer to save their song preferences") } + try library.savePreference(song:song,singer:selectedSinger,value:value); try refresh() + } + } + func play(_ song: Song, singer: Int64? = nil) throws { + guard let library else { throw LibraryError.message("Library unavailable") } + let singer = singer ?? selectedSinger + if let singer, let currentVenue, try !library.members(of:currentVenue).contains(where: { $0.id == singer }) { throw LibraryError.message("This singer is not in the current venue") } + let preference = try singer.map { try library.preference(song:song.id,singer:$0) } ?? library.defaultPreference(song:song.id) + try player.load(song,preference:preference) + do { try library.updateDuration(song.id,seconds:player.duration);try library.record(song.id,singer:singer,venue:currentVenue); selectedSinger = singer; try refresh() } + catch { player.stop(); throw error } + } + func enqueue(_ song: Int64) { perform { try library?.enqueue(song:song,singer:selectedSinger,venue:currentVenue); try refresh() } } + func tonightSongs(for singer: Int64) -> [Song] { (tonightSongIDs[singer] ?? []).compactMap { songIndex[$0] } } + func addToTonight(_ song: Int64,singer: Int64? = nil) { + perform { + guard let library,let currentVenue,let singer = singer ?? selectedSinger else { throw LibraryError.message("Choose a current venue and singer, then add the song to Tonight") } + try library.addTonight(song:song,singer:singer,venue:currentVenue);try refresh() + } + } + func removeFromTonight(_ song: Int64,singer: Int64) { + perform { guard let library,let currentVenue else { return };try library.removeTonight(song:song,singer:singer,venue:currentVenue);try refresh() } + } + func playTonight(_ song: Song,singer: Int64) { + perform { + try play(song,singer:singer) + if let entry = queue.first(where:{ $0.songID == song.id && $0.singerID == singer }) { try library?.removeQueue(entry.id) } + try refresh() + } + } + func playQueueNext() { + perform { + guard let row = queue.first, let song = songs.first(where: { $0.id == row.songID }) else { return } + // Explicitly clear selection for unassigned queue entries. + selectedSinger = row.singerID + try play(song,singer:row.singerID); try library?.removeQueue(row.id); try refresh() + } + } + func moveQueue(_ id: Int64, by offset: Int) { perform { if let currentVenue { try library?.moveQueue(id,venue:currentVenue,by:offset); try refresh() } } } + func removeQueue(_ id: Int64) { perform { try library?.removeQueue(id); try refresh() } } + func createPlaylist(_ name: String) { perform { try library?.addEntity("playlists",name:name); try refresh() } } + func addToPlaylist(_ playlist: Int64, song: Int64) { perform { try library?.addToPlaylist(playlist,song:song); try refresh() } } + func setFavourite(_ song: Int64, singer: Int64, included: Bool) { perform { try library?.setFavourite(song:song,singer:singer,included:included);try refresh() } } + func updateTuning(_ song: Int64, pitch: Double? = nil, tempo: Double? = nil) { + perform { + guard let library else { throw LibraryError.message("Library unavailable") } + var value = preference(song) + if let pitch { value.pitch = pitch };if let tempo { value.tempo = tempo } + if let selectedSinger { try library.savePreference(song:song,singer:selectedSinger,value:value) } + else { try library.saveDefaultPreference(song:song,value:value) } + if player.currentSongID == song { if pitch != nil { player.pitch = value.pitch };if tempo != nil { player.tempo = value.tempo } } + try refresh() + } + } + func saveStore(_ store: MusicStore) throws { guard let library else { throw LibraryError.message("Library unavailable") };try library.saveStore(store);try refresh() } + func deleteStore(_ id: Int64) { perform { try library?.deleteStore(id);try refresh() } } + func scanDurations() { + guard !readingDurations else { return };readingDurations = true + let songs = self.songs.filter { $0.duration == 0 } + Task.detached(priority:.utility) { [weak self] in + var durations = [(Int64,Double)]() + for song in songs { + if let file = try? AVAudioFile(forReading:URL(fileURLWithPath:song.audio)) { durations.append((song.id,Double(file.length)/file.processingFormat.sampleRate)) } + } + let completedDurations = durations + await MainActor.run { [weak self] in + guard let self else { return } + self.perform { for (id,value) in completedDurations { try self.library?.updateDuration(id,seconds:value) };try self.refresh() } + self.readingDurations = false + } + } + } + func updateMetadata(_ song: Song,title: String,artist: String,album: String,label: String,rating: Int) throws { + guard let library else { throw LibraryError.message("Library unavailable") } + try library.updateMetadata(song:song.id,title:title,artist:artist,album:album,label:label,rating:rating) + if player.currentSongID == song.id { player.title = title.trimmingCharacters(in:.whitespacesAndNewlines) } + try refresh() + } + func importFiles(_ urls: [URL]) { perform { guard let library else { throw LibraryError.message("Library unavailable") }; try Importer(media:root.appendingPathComponent("Media")).run(urls,library:library); try refresh() } } +} +@main struct KJamsCloneApp: App { + @StateObject private var model = AppModel() + var body: some Scene { + Window("KJamsClone", id: "main") { + LibraryView(model: model).frame(minWidth: 1060, minHeight: 680).preferredColorScheme(.dark) + }.defaultSize(width: 1260, height: 840) + .commands { + CommandGroup(replacing: .newItem) { + Button("New Singer…") { model.beginSinger() }.keyboardShortcut("n", modifiers: [.command, .shift]) + Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option]) + } + } + Window("Video", id: "lyrics") { + LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark) + }.defaultSize(width: 600, height: 400) + } +} +private enum Classic { + static let frame = Color(red: 0.15, green: 0.15, blue: 0.15) + static let navy = Color(red: 0.008, green: 0.065, blue: 0.16) + static let blue = Color(red: 0.015, green: 0.17, blue: 0.42) + static let header = Color(white: 0.115) + static let line = Color(white: 0.42) +} +struct LyricsView: View { + @ObservedObject var player: Player + var body: some View { + ZStack { + Color.black + if let image = player.image { + Image(decorative: image, scale: 1).interpolation(.none).resizable().aspectRatio(1.5, contentMode: .fit) + } + } + } +} +/// This is another view of the same published CGImage used by the Video window. +/// It owns no decoder, audio player or clock, so seeking/pause/tempo stay identical. +private struct SidebarVideoMonitor: View { + let player: Player + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Label("Video Monitor", systemImage: "tv") + .font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7)) + LyricsView(player: player) + .aspectRatio(1.5, contentMode: .fit) + .clipped() + .overlay(Rectangle().strokeBorder(.white.opacity(0.25), lineWidth: 1)) + .accessibilityLabel("Video monitor, synchronized with the Video window") + }.frame(maxWidth: .infinity) + } +} +private struct SilverSquareStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label.foregroundStyle(Color(white: 0.25)).padding(.horizontal, 6).padding(.vertical, 3) + .background(LinearGradient(colors: [Color(white: 0.95), Color(white: 0.64)], startPoint: .top, endPoint: .bottom), in: RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Color(white: 0.45), lineWidth: 1.5)) + .overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(.white.opacity(0.7)).padding(3)) + .opacity(configuration.isPressed ? 0.7 : 1) + } +} +private struct SilverButton: View { + let symbol: String + let label: String + var size: CGFloat = 40 + var active = false + var enabled = true + let action: () -> Void + var body: some View { + Button(action: action) { + Image(systemName: symbol).font(.system(size: size * 0.41, weight: .semibold)) + .foregroundStyle(active ? Color.blue : Color(white: 0.24)) + .frame(width: size, height: size) + .background(LinearGradient(colors: [.white, Color(white: 0.85), Color(white: 0.63)], startPoint: .top, endPoint: .bottom), in: Circle()) + .overlay(Circle().strokeBorder(Color(white: 0.45), lineWidth: 1.5)) + .overlay(Circle().strokeBorder(.white.opacity(0.65), lineWidth: 1).padding(3)) + .shadow(color: .black.opacity(0.7), radius: 1, y: 1) + }.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.72) + .help(label).accessibilityLabel(label) + } +} +struct LibraryView: View { + @ObservedObject var model: AppModel + @State private var selected: Int64? + @State private var search = "" + @State private var name = "" + @State private var importing = false + @State private var source = "Library" + @State private var singersExpanded = true + @State private var expandedSingers = Set() + @State private var expandedSongLists = Set() + @State private var songPickerSinger: PersonPlace? + @State private var songPickerTonight = false + @State private var singerSections = [Int64:String]() + @State private var singerSelections = [Int64:Int64]() + @State private var presentedSinger: Int64? + @State private var singerSection = "All Songs" + @State private var storesExpanded = true + @State private var focusedStore: Int64? + @State private var editingSong: Song? + @State private var librarySort = "Song Name" + @State private var sortAscending = true + @Environment(\.openWindow) private var openWindow + private var visibleSongs: [Song] { + let filtered = model.songs.filter { search.isEmpty || ($0.title+" "+$0.artist+" "+$0.album).localizedCaseInsensitiveContains(search) } + return filtered.sorted { a,b in + if a.id == b.id { return false } + if librarySort == "Duration" { return a.duration == b.duration ? a.id < b.id : sortAscending ? a.duration < b.duration : a.duration > b.duration } + if librarySort == "Rating" { return a.rating == b.rating ? a.id < b.id : sortAscending ? a.rating < b.rating : a.rating > b.rating } + let first = librarySort == "Artist" ? a.artist : librarySort == "Album" ? a.album : a.title + let second = librarySort == "Artist" ? b.artist : librarySort == "Album" ? b.album : b.title + let order = first.localizedStandardCompare(second) + return order == .orderedSame ? a.id < b.id : sortAscending ? order == .orderedAscending : order == .orderedDescending + } + } + + private var selectedSong: Song? { model.songs.first { $0.id == selected } } + var body: some View { + VStack(spacing: 0) { + TransportView(player: model.player, search: $search, hasSelection: selectedSong != nil, + play: playSelected, next: playNext, display: { openWindow(id: "lyrics") }) + .padding(.horizontal, 18).padding(.top, 18).padding(.bottom, 22) + HSplitView { + sidebar.frame(minWidth: 190, idealWidth: 220, maxWidth: 240) + VStack(spacing: 12) { + if source == "Library" { songTable } + else if source == "Queue" { queuePane } + else if source == "History" { historyPane } + else if source == "Playlists" { playlistPane } + else if source == "Singer Songs" { singerSongPane } + else if source == "Music Stores" { MusicStoresPane(model:model,selected:$focusedStore) } + else { directory } + detailPanel.frame(height: 135) + }.padding(.leading, 12).frame(minWidth: 780) + }.padding(.horizontal, 18) + footer.padding(.horizontal, 18).padding(.vertical, 12) + } + .background(Classic.frame) + .sheet(item: $model.creating) { kind in + AddEntitySheet(model: model, kind: kind) { id in + if kind == .singer { expandedSingers.insert(id);expandedSongLists.insert(id);singersExpanded = true;showSinger(id,section:"All Songs") } else { model.chooseVenue(id) } + } + } + .sheet(item:$editingSong) { song in SongMetadataSheet(model:model,song:song) } + .sheet(item:$songPickerSinger) { singer in SingerSongsSheet(model:model,singer:singer,tonight:songPickerTonight) } + .onChange(of:selected) { song in + if source == "Singer Songs",let singer = model.selectedSinger,let song { singerSelections[singer] = song } + } + .onChange(of:model.selectedSinger) { singer in + if source == "Singer Songs",presentedSinger != singer { showSinger(singer,section:nil) } + } + .fileImporter(isPresented: $importing, allowedContentTypes: [.audio, .zip, UTType(filenameExtension: "cdg") ?? .data], allowsMultipleSelection: true) { result in + switch result { + case .success(let urls): model.importFiles(urls) + case .failure(let error): model.error = error.localizedDescription + } + } + .alert("KJamsClone", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { + Button("OK") { model.error = nil } + } message: { Text(model.error ?? "") } + } + private func showSinger(_ id: Int64?,section: String? = nil) { + if source == "Singer Songs",let previous = presentedSinger { + singerSections[previous] = singerSection + if let selected { singerSelections[previous] = selected } + } + presentedSinger = id;model.selectedSinger = id + singerSection = section ?? id.flatMap { singerSections[$0] } ?? "Tonight" + if let id { singerSections[id] = singerSection;selected = singerSelections[id] } + source = "Singer Songs" + } + private func sourceCount(_ source: String) -> Int { + switch source { + case "Library": return model.songs.count + case "Singers": return model.venueSingers.count + case "Venues": return model.venues.count + case "Queue": return model.queue.count + case "History": return model.history.count + default: return model.playlists.count + } + } + private var sidebar: some View { + VStack(alignment:.leading,spacing:0) { + Text("Source").font(.system(size:15,weight:.semibold)).padding(.horizontal,10).frame(height:31) + .frame(maxWidth:.infinity,alignment:.leading).background(Classic.header) + ScrollView { + VStack(spacing:0) { + sourceRow("Library",icon:"music.note.house.fill",color:.yellow) + HStack(spacing:5) { + Button { singersExpanded.toggle() } label: { Image(systemName:singersExpanded ? "chevron.down" : "chevron.right").font(.system(size:10)).frame(width:12) }.buttonStyle(.plain).accessibilityLabel("Expand singers") + Button { source = "Singers" } label: { HStack { Image(systemName:"person.2.fill").foregroundStyle(.green);Text("Singers");Spacer();Text("\(model.venueSingers.count)").font(.caption).foregroundStyle(.secondary) } }.buttonStyle(.plain) + Button { model.beginSinger() } label: { Image(systemName:"plus.circle.fill").foregroundStyle(.green) }.buttonStyle(.plain).help("New singer and songs") + }.padding(.horizontal,10).frame(height:34).background(source == "Singers" ? Color.blue.opacity(0.65) : .clear) + if singersExpanded { + Text(model.currentVenueName).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1).padding(.leading,30).frame(maxWidth:.infinity,alignment:.leading).padding(.vertical,4) + ForEach(model.venueSingers) { singer in sidebarSinger(singer) } + if model.venueSingers.isEmpty { Text("Add a singer with +").font(.caption).foregroundStyle(.secondary).padding(8) } + } + HStack { + Button { storesExpanded.toggle() } label: { Image(systemName:storesExpanded ? "chevron.down" : "chevron.right").font(.system(size:10)) }.buttonStyle(.plain) + Button { source = "Music Stores" } label: { Label("Music Stores",systemImage:"bag.fill").foregroundStyle(.green);Spacer() }.buttonStyle(.plain) + }.padding(.horizontal,12).frame(height:34) + if storesExpanded { + ForEach(model.musicStores) { store in + Button { focusedStore = store.id;source = "Music Stores" } label: { Label(store.name,systemImage:"globe").font(.system(size:12)).lineLimit(1).padding(.leading,32).frame(maxWidth:.infinity,alignment:.leading).frame(height:26) }.buttonStyle(.plain) + } + Button("Configure Stores…") { source = "Music Stores" }.buttonStyle(.plain).font(.system(size:11)).foregroundStyle(.secondary).padding(.leading,32).frame(maxWidth:.infinity,alignment:.leading).frame(height:25) + } + sourceRow("Venues",icon:"building.2.fill",color:.cyan) + sourceRow("Queue",icon:"list.number",color:.orange) + sourceRow("History",icon:"clock.arrow.circlepath",color:.purple) + sourceRow("Playlists",icon:"music.note.list",color:.mint) + } + } + SidebarVideoMonitor(player:model.player).padding(10) + }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line,lineWidth:1)) + } + private func sourceRow(_ title: String, icon: String, color: Color) -> some View { + Button { source = title } label: { + HStack(spacing:10) { + Image(systemName:icon).foregroundStyle(color).frame(width:22) + Text(title).font(.system(size:16,weight:.medium));Spacer() + Text("\(sourceCount(title))").font(.system(size:11)).foregroundStyle(.white.opacity(0.6)) + }.padding(.horizontal,12).frame(height:34).contentShape(Rectangle()) + .background(source == title ? Color(red:0,green:0.36,blue:1) : .clear) + }.buttonStyle(.plain) + } + private func sidebarSinger(_ singer: PersonPlace) -> some View { + VStack(alignment:.leading,spacing:0) { + HStack(spacing:5) { + Button { + if expandedSingers.contains(singer.id) { expandedSingers.remove(singer.id) } else { expandedSingers.insert(singer.id) } + } label: { Image(systemName:expandedSingers.contains(singer.id) ? "chevron.down" : "chevron.right").font(.system(size:9)).frame(width:12) }.buttonStyle(.plain).accessibilityLabel("Expand \(singer.name)’s songs") + Button { showSinger(singer.id) } label: { + HStack(spacing:5) { Text(singer.name).lineLimit(1);Spacer();Text("\(model.singerSongIDs[singer.id]?.count ?? 0)").font(.system(size:10)).foregroundStyle(.secondary) } + }.buttonStyle(.plain) + Menu { + Button("Add to Tonight…") { showSinger(singer.id,section:"Tonight");songPickerTonight = true;songPickerSinger = singer } + Button("Manage All Songs…") { showSinger(singer.id,section:"All Songs");songPickerTonight = false;songPickerSinger = singer } + } label: { Image(systemName:"plus").font(.system(size:10,weight:.bold)) }.menuStyle(.borderlessButton).frame(width:16).help("Add songs to \(singer.name)’s Tonight or repertoire") + }.font(.system(size:13,weight:.medium)).padding(.leading,25).padding(.trailing,12).frame(height:29) + .background(model.selectedSinger == singer.id && source == "Singer Songs" ? Color.blue.opacity(0.25) : .clear) + if expandedSingers.contains(singer.id) { + singerFolder("Tonight",icon:"music.note.list",singer:singer) + singerFolder("Favourites",icon:"star.fill",singer:singer) + singerFolder("History",icon:"clock.arrow.circlepath",singer:singer) + HStack(spacing:3) { + Button { if expandedSongLists.contains(singer.id) { expandedSongLists.remove(singer.id) } else { expandedSongLists.insert(singer.id) } } label: { Image(systemName:expandedSongLists.contains(singer.id) ? "chevron.down" : "chevron.right").font(.system(size:9)) }.buttonStyle(.plain) + singerFolder("All Songs",icon:"folder",singer:singer) + }.padding(.leading,28) + if expandedSongLists.contains(singer.id) { + ForEach(model.songs(for:singer.id)) { song in + Button { showSinger(singer.id,section:"All Songs");selected = song.id;singerSelections[singer.id] = song.id } label: { + Label(song.title,systemImage:"music.note").font(.system(size:11)).lineLimit(1).frame(maxWidth:.infinity,alignment:.leading).padding(.leading,44).padding(.trailing,8).frame(height:25) + }.buttonStyle(.plain) + .contextMenu { + Button("Play for \(singer.name)") { model.selectedSinger = singer.id;model.perform { try model.play(song,singer:singer.id) } } + Button("Queue for \(singer.name)") { model.selectedSinger = singer.id;model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Button(model.favourites[singer.id]?.contains(song.id) == true ? "Remove Favourite" : "Add to Favourites") { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } + Button("Remove from Singer",role:.destructive) { model.assign(song.id,to:singer.id,included:false) } + } + } + if model.songs(for:singer.id).isEmpty { Text("No songs · use +").font(.system(size:10)).foregroundStyle(.secondary).padding(.leading,44).padding(.vertical,5) } + } + } + } + } + private func singerFolder(_ title: String, icon: String, singer: PersonPlace) -> some View { + Button { showSinger(singer.id,section:title) } label: { + Label { Text(title + (title == "Tonight" ? " (\(model.tonightSongIDs[singer.id]?.count ?? 0))" : "")) } icon: { if title == "Favourites" { GoldStar(filled:true,size:12) } else { Image(systemName:icon) } }.font(.system(size:11)).foregroundStyle(title == "Favourites" ? Color(red:1,green:0.75,blue:0.16) : Color.white.opacity(0.75)).padding(.leading,title == "All Songs" ? 0 : 43).frame(maxWidth:.infinity,alignment:.leading).frame(height:25) + }.buttonStyle(.plain) + } + private var singerSongPane: some View { + VStack(alignment:.leading,spacing:12) { + if let singer = model.singers.first(where:{ $0.id == model.selectedSinger }) { + HStack { Text("\(singer.name) · \(singerSection)").font(.headline);Spacer();Button(singerSection == "Tonight" ? "Add to Tonight…" : "Add Songs…") { songPickerTonight = singerSection == "Tonight";songPickerSinger = singer } } + Text("\(model.currentVenueName) · Singer song lists follow them across venues.").font(.caption).foregroundStyle(.secondary) + if singerSection == "History" { + ScrollView { LazyVStack(alignment:.leading,spacing:12) { ForEach(model.history.filter { $0.singerID == singer.id }) { row in HStack { Text(row.date).font(.caption.monospaced());Text(row.song);Spacer() } } } } + } else if singerSection == "Tonight" { + ScrollView { + VStack(spacing:10) { + ForEach(model.tonightSongs(for:singer.id)) { song in + HStack { + Button { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } label: { GoldStar(filled:model.favourites[singer.id]?.contains(song.id) == true,size:15) }.buttonStyle(.plain).help("Toggle favourite") + Text(song.title).lineLimit(1);Spacer() + Text(model.queue.contains(where:{ $0.songID == song.id && $0.singerID == singer.id }) ? "Queued" : "Ready").font(.caption).foregroundStyle(.secondary) + Button("Play") { model.playTonight(song,singer:singer.id) } + Button("Queue Again") { model.selectedSinger = singer.id;model.enqueue(song.id) } + Button("Remove") { model.removeFromTonight(song.id,singer:singer.id) } + }.padding(8).background(.white.opacity(0.035)) + } + if model.tonightSongs(for:singer.id).isEmpty { Text("No songs in \(singer.name)’s Tonight list. Use Add to Tonight… above.").foregroundStyle(.secondary).padding(.top,20) } + } + } + } else { + ScrollView { + VStack(spacing:10) { + ForEach(model.songs(for:singer.id).filter { singerSection != "Favourites" || model.favourites[singer.id]?.contains($0.id) == true }) { song in + HStack { + Button { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } label: { GoldStar(filled:model.favourites[singer.id]?.contains(song.id) == true,size:15) }.buttonStyle(.plain).help("Favourite for this singer") + Text(song.title).lineLimit(1);Spacer() + Button("Play") { model.selectedSinger = singer.id;model.perform { try model.play(song,singer:singer.id) } } + Button("Add to Tonight") { model.addToTonight(song.id,singer:singer.id) }.disabled(model.currentVenue == nil) + Button("Remove") { model.assign(song.id,to:singer.id,included:false) } + }.padding(8).background(.white.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = song.id } + } + if model.songs(for:singer.id).isEmpty { Text("Use Add Songs… or the + beside this singer in the sidebar.").foregroundStyle(.secondary).padding(.top,25) } + } + } + } + } else { Text("Choose a singer from the sidebar.").foregroundStyle(.secondary);Spacer() } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + private var songTable: some View { + GeometryReader { geometry in + ScrollView(.horizontal) { + VStack(spacing: 0) { + tableHeader(width: max(geometry.size.width, 960)) + ScrollView(.vertical) { + LazyVStack(spacing: 0) { + ForEach(visibleSongs) { song in songRow(song, width: max(geometry.size.width, 960)) } + }.frame(maxWidth: .infinity, alignment: .topLeading) + } + }.frame(width: max(geometry.size.width, 960), height: geometry.size.height) + } + }.background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) + } + private func widths(_ total: CGFloat) -> [CGFloat] { [44,max(220,total-600),110,100,65,82,90,109] } + private func tableHeader(width: CGFloat) -> some View { + let sizes = widths(width) + return HStack(spacing:0) { + ForEach(Array(["ID","Song Name","Artist","Album","Duration","Pitch","Tempo","Rating"].enumerated()),id:\.offset) { index,title in + Button { + guard ["Song Name","Artist","Album","Duration","Rating"].contains(title) else { return } + if librarySort == title { sortAscending.toggle() } else { librarySort = title;sortAscending = true } + } label: { HStack(spacing:3) { Text(title);if librarySort == title { Image(systemName:sortAscending ? "chevron.up" : "chevron.down").font(.system(size:8)) } }.font(.system(size:12,weight:.medium)).lineLimit(1).padding(.leading,7).frame(width:sizes[index],height:30,alignment:.leading) } + .buttonStyle(.plain).overlay(alignment:.trailing) { Rectangle().fill(Classic.line).frame(width:1).padding(.vertical,4) } + } + }.foregroundStyle(.white).background(Classic.header).overlay(alignment:.bottom) { Rectangle().fill(Color(white:0.65)).frame(height:1) } + } + private func durationLabel(_ seconds: Double) -> String { seconds > 0 ? String(format:"%d:%02d",Int(seconds)/60,Int(seconds)%60) : "—" } + private func songRow(_ song: Song,width: CGFloat) -> some View { + let sizes = widths(width) + return HStack(spacing:0) { + Text("\(song.id)").foregroundStyle(.secondary).frame(width:sizes[0]) + Text(song.title).lineLimit(1).padding(.leading,7).frame(width:sizes[1],alignment:.leading) + Text(song.artist.isEmpty ? "—" : song.artist).foregroundStyle(.secondary).lineLimit(1).padding(.leading,7).frame(width:sizes[2],alignment:.leading) + Text(song.album.isEmpty ? "—" : song.album).foregroundStyle(.secondary).lineLimit(1).padding(.leading,7).frame(width:sizes[3],alignment:.leading) + Text(durationLabel(song.duration)).font(.system(size:11,design:.monospaced)).frame(width:sizes[4]) + HStack(spacing:2) { + Text(String(format:"%+.0f",model.preference(song.id).pitch)).font(.system(size:11,design:.monospaced)).frame(width:30) + Stepper("Pitch",value:Binding(get:{ model.preference(song.id).pitch },set:{ model.updateTuning(song.id,pitch:$0) }),in:-12...12,step:1).labelsHidden().controlSize(.mini).fixedSize() + }.frame(width:sizes[5]).help("Saved pitch; applies to the selected singer, or song default") + Picker("Tempo",selection:Binding(get:{ model.preference(song.id).tempo },set:{ model.updateTuning(song.id,tempo:$0) })) { + ForEach(10..<31) { Text("\($0*5)%").tag(Double($0)/20) } + }.labelsHidden().controlSize(.mini).frame(width:sizes[6]-8).padding(.horizontal,4).help("Saved tempo; applies live if this song is playing") + GoldRating(rating:Binding(get:{ song.rating },set:{ value in model.perform { try model.library?.rate(song.id,value);try model.refresh() } })).frame(width:sizes[7]) + }.font(.system(size:12)).foregroundStyle(.white).frame(height:30) + .background(selected == song.id ? Color.blue.opacity(0.5) : song.id % 2 == 0 ? Color.white.opacity(0.025) : .clear) + .contentShape(Rectangle()).onTapGesture(count:2) { selected = song.id;playSelected() }.onTapGesture { selected = song.id } + .contextMenu { + Button("Play") { selected = song.id; playSelected() } + Button("Edit Metadata…") { editingSong = song } + Menu("Add to Singer") { + ForEach(model.venueSingers) { singer in Button(singer.name) { model.assign(song.id,to:singer.id);model.selectedSinger = singer.id;expandedSingers.insert(singer.id) } } + Divider();Button("New Singer + This Song…") { model.beginSinger(song:song.id) } + } + Menu("Add to Tonight") { ForEach(model.venueSingers) { singer in Button(singer.name) { model.addToTonight(song.id,singer:singer.id) } } }.disabled(model.currentVenue == nil) + Button("Add to Current Venue Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Menu("Add to Playlist") { ForEach(model.playlists) { playlist in Button(playlist.name) { model.addToPlaylist(playlist.id,song:song.id) } } } + } + } + @State private var managingRoster = false + @State private var rosterNewName = "" + @State private var editingEntity: PersonPlace? + @State private var renameText = "" + @State private var pendingDelete: PersonPlace? + private var directory: some View { + VStack(alignment:.leading,spacing:0) { + HStack { + Text(source == "Singers" ? "Singers · \(model.currentVenueName)" : "Venues").font(.headline) + Spacer() + if source == "Singers" { Button("Manage Venue Singers…") { managingRoster = true }.disabled(model.currentVenue == nil) } + Button(source == "Singers" ? "New Singer…" : "New Venue…") { if source == "Singers" { model.beginSinger() } else { model.creating = .venue } } + }.padding(12).background(Classic.header) + ScrollView { + VStack(alignment:.leading,spacing:0) { + if source == "Singers" && model.venueSingers.isEmpty { + Text("No singers at this venue. Add a singer or use Manage Venue Singers to include existing singers.").foregroundStyle(.secondary).padding() + } + ForEach(source == "Singers" ? model.venueSingers : model.venues) { entity in + HStack { + Button { + if source == "Singers" { model.selectedSinger = entity.id } else { model.chooseVenue(entity.id) } + } label: { + HStack { + Image(systemName:source == "Singers" ? "person.fill" : "building.2.fill") + Text(entity.name); Spacer() + if entity.id == (source == "Singers" ? model.selectedSinger : model.currentVenue) { + Text(source == "Singers" ? "Selected" : "Current venue").font(.caption).foregroundStyle(.cyan) + Image(systemName:"checkmark.circle.fill").foregroundStyle(.cyan) + } + }.contentShape(Rectangle()) + }.buttonStyle(.plain) + Button("Rename") { renameText = entity.name; editingEntity = entity } + Button("Delete",role:.destructive) { pendingDelete = entity } + }.padding(12) + } + } + } + HStack { + TextField(source == "Singers" ? "Singer name" : "Venue name",text:$name).textFieldStyle(.roundedBorder).onSubmit(addEntity) + Button(source == "Singers" ? "Add Singer" : "Add Venue",action:addEntity).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + }.padding(12) + }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + .sheet(isPresented:$managingRoster) { rosterSheet } + .sheet(item:$editingEntity) { entity in + VStack(alignment:.leading,spacing:16) { + Text("Rename \(source == "Singers" ? "Singer" : "Venue")").font(.headline) + TextField("Name",text:$renameText).textFieldStyle(.roundedBorder) + HStack { Button("Cancel") { editingEntity = nil }; Spacer(); Button("Save") { model.rename(source == "Singers" ? "singers" : "venues",id:entity.id,name:renameText); editingEntity = nil }.disabled(renameText.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) } + }.padding(24).frame(width:360) + } + .confirmationDialog("Delete \(pendingDelete?.name ?? "entry")? Historical plays are kept. Venue memberships and queued entries for it will be removed.",isPresented:Binding(get:{ pendingDelete != nil },set:{ if !$0 { pendingDelete = nil } })) { + Button("Delete",role:.destructive) { if let entity = pendingDelete { model.delete(source == "Singers" ? "singers" : "venues",id:entity.id) }; pendingDelete = nil } + } + } + private var rosterSheet: some View { + VStack(alignment:.leading,spacing:16) { + Text("Singers at \(model.currentVenueName)").font(.title2.bold()) + Text("A singer can belong to any number of venues. Unchecking removes them from this venue only.").foregroundStyle(.secondary) + ScrollView { + VStack(alignment:.leading,spacing:12) { + ForEach(model.singers) { singer in + Toggle(singer.name,isOn:Binding(get:{ model.venueSingers.contains { $0.id == singer.id } },set:{ model.membership(singer:singer.id,included:$0) })) + } + }.frame(maxWidth:.infinity,alignment:.leading) + }.frame(minHeight:120,maxHeight:280) + HStack { + TextField("New singer name",text:$rosterNewName).textFieldStyle(.roundedBorder) + Button("Add") { model.perform { _ = try model.createEntity(.singer,name:rosterNewName); rosterNewName = "" } }.disabled(rosterNewName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + Spacer(); Button("Done") { managingRoster = false }.keyboardShortcut(.defaultAction) + } + }.padding(24).frame(width:460) + } + private var detailPanel: some View { + VStack(alignment:.leading,spacing:9) { + HStack(spacing:10) { + Picker("Current venue",selection:Binding(get:{ model.currentVenue },set:{ model.chooseVenue($0) })) { + Text("Select venue…").tag(nil as Int64?) + ForEach(model.venues) { Text($0.name).tag(Optional($0.id)) } + } + Button { model.creating = .venue } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add venue") + Picker("Singer",selection:$model.selectedSinger) { + Text("None").tag(nil as Int64?) + ForEach(model.venueSingers) { Text($0.name).tag(Optional($0.id)) } + } + Button { model.beginSinger() } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add singer") + Menu("Add to Singer") { + ForEach(model.venueSingers) { singer in Button(singer.name) { if let selected { model.assign(selected,to:singer.id);model.selectedSinger = singer.id;expandedSingers.insert(singer.id) } } } + Divider();Button("New Singer + Song…") { model.beginSinger(song:selected) } + }.disabled(selected == nil) + Button("Add to Tonight") { if let selected { model.addToTonight(selected) } }.disabled(selected == nil || model.currentVenue == nil || model.selectedSinger == nil) + } + PlaybackSettings(player:model.player,model:model,selectedSong:selected) + SeekBar(player:model.player) + }.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity) + .background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + @State private var playlistName = "" + @State private var selectedPlaylist: Int64? + private var queuePane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { Text("Queue · \(model.currentVenueName)").font(.headline); Spacer(); Toggle("Auto play next",isOn:$model.autoAdvance).toggleStyle(.checkbox); Button("Play Next") { model.playQueueNext() }.disabled(model.queue.isEmpty) } + ScrollView { + VStack(spacing:0) { + ForEach(Array(model.queue.enumerated()),id:\.element.id) { index,row in + HStack { + Text("\(index+1)").foregroundStyle(.secondary).frame(width:24) + Text(row.singer).frame(width:140,alignment:.leading) + Text(row.title).lineLimit(1); Spacer() + Button { model.moveQueue(row.id,by:-1) } label: { Image(systemName:"arrow.up") }.disabled(index == 0) + Button { model.moveQueue(row.id,by:1) } label: { Image(systemName:"arrow.down") }.disabled(index == model.queue.count-1) + Button("Remove") { model.removeQueue(row.id) } + }.padding(.vertical,8) + } + if model.queue.isEmpty { Text("Choose a venue and singer, select a library song, then click Queue Song.").foregroundStyle(.secondary).padding(.top,30) } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + @State private var historyAllVenues = false + private var displayedHistory: [Performance] { + if historyAllVenues { return (try? model.library?.history(venue:nil,singer:model.selectedSinger)) ?? [] } + return model.history.filter { model.selectedSinger == nil || $0.singer == model.singers.first(where: { $0.id == model.selectedSinger })?.name } + } + private var historyPane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { Text("History · \(historyAllVenues || model.currentVenue == nil ? "All venues" : model.currentVenueName)").font(.headline); Spacer(); Toggle("All venues",isOn:$historyAllVenues).toggleStyle(.checkbox) } + ScrollView { + LazyVStack(alignment:.leading,spacing:12) { + ForEach(displayedHistory) { row in + HStack { Text(row.date).font(.system(.caption,design:.monospaced)); Text(row.singer).frame(width:130,alignment:.leading); Text(row.song); Spacer(); Text(row.venue).foregroundStyle(.secondary) } + } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + private var playlistPane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { + Picker("Playlist",selection:$selectedPlaylist) { Text("Choose…").tag(nil as Int64?); ForEach(model.playlists) { Text($0.name).tag(Optional($0.id)) } } + TextField("New playlist name",text:$playlistName).textFieldStyle(.roundedBorder) + Button("Create") { model.createPlaylist(playlistName); selectedPlaylist = model.playlists.first { $0.name == playlistName.trimmingCharacters(in:.whitespacesAndNewlines) }?.id; playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + if let selectedPlaylist { + Button("Rename") { model.rename("playlists",id:selectedPlaylist,name:playlistName); playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + Button("Delete",role:.destructive) { model.delete("playlists",id:selectedPlaylist); self.selectedPlaylist = nil } } + } + ScrollView { + if let selectedPlaylist { + let ids = (try? model.library?.playlistSongIDs(selectedPlaylist)) ?? [] + VStack(alignment:.leading,spacing:10) { + ForEach(ids,id:\.self) { id in + if let song = model.songs.first(where: { $0.id == id }) { + HStack { + Text(song.title); Spacer() + Button("Play") { model.perform { try model.play(song) } } + Button("Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Button("Remove") { model.perform { try model.library?.removeFromPlaylist(selectedPlaylist,song:id); try model.refresh() } } + } + } + } + } + } + Text("Add songs using the library row’s context menu.").foregroundStyle(.secondary).padding(.top,20) + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + private var footer: some View { + HStack { + Button { importing = true } label: { Image(systemName: "plus").font(.system(size: 20, weight: .bold)).frame(width: 32, height: 24) } + .help("Import audio, MP3+G or ZIP").accessibilityLabel("Import songs") + RepeatButton(player: model.player).padding(.leading, 12) + Button(model.readingDurations ? "Reading…" : "Read Durations") { model.scanDurations() }.controlSize(.small).disabled(model.readingDurations).help("Read durations for songs imported by older versions") + Button("Edit Metadata…") { editingSong = selectedSong }.controlSize(.small).disabled(selectedSong == nil) + Spacer() + Text("\(visibleSongs.count) \(visibleSongs.count == 1 ? "Song" : "Songs")").font(.system(size: 14, weight: .medium)) + Spacer() + Button { openWindow(id: "lyrics") } label: { Image(systemName: "rectangle.on.rectangle").frame(width: 32, height: 24) } + .help("Open video window").accessibilityLabel("Open video window") + }.buttonStyle(SilverSquareStyle()) + } + private func playSelected() { + guard let song = selectedSong else { return } + model.perform { try model.play(song) } + } + private func playNext() { + guard !visibleSongs.isEmpty else { return } + let index = visibleSongs.firstIndex { $0.id == selected } ?? -1 + selected = visibleSongs[(index + 1) % visibleSongs.count].id; playSelected() + } + private func addEntity() { + model.perform { + let kind: EntityKind = source == "Singers" ? .singer : .venue + let id = try model.createEntity(kind, name: name) + if kind == .singer { model.selectedSinger = id } else { model.chooseVenue(id) } + name = "" + } + } +} +/// Drawn with literal colours, so native menu/button tint cannot turn favourite stars white. +private struct GoldStar: View { + var filled: Bool + var size: CGFloat = 14 + var body: some View { + Canvas { context,bounds in + let centre = CGPoint(x:bounds.width/2,y:bounds.height/2),outer = min(bounds.width,bounds.height)*0.46 + var star = Path() + for point in 0..<10 { + let angle = Double(point)*Double.pi/5-Double.pi/2,radius = point % 2 == 0 ? outer : outer*0.43 + let p = CGPoint(x:centre.x+CGFloat(cos(angle))*radius,y:centre.y+CGFloat(sin(angle))*radius) + if point == 0 { star.move(to:p) } else { star.addLine(to:p) } + } + star.closeSubpath() + let gold = Color(red:1,green:0.73,blue:0.08) + if filled { context.fill(star,with:.color(gold)) } + else { context.stroke(star,with:.color(gold.opacity(0.7)),lineWidth:1) } + }.frame(width:size,height:size).accessibilityHidden(true) + } +} +private struct GoldRating: View { + @Binding var rating: Int + var body: some View { + HStack(spacing:2) { ForEach(1..<6) { value in Button { rating = rating == value ? 0 : value } label: { GoldStar(filled:value <= rating) }.buttonStyle(.plain).help("\(value) stars; click the selected rating to clear").accessibilityLabel("Set \(value) stars") } } + } +} +private struct SongMetadataSheet: View { + @ObservedObject var model: AppModel + let song: Song + @Environment(\.dismiss) private var dismiss + @State private var title = "" + @State private var artist = "" + @State private var album = "" + @State private var label = "" + @State private var rating = 0 + @State private var error: String? + var body: some View { + VStack(alignment:.leading,spacing:14) { + Text("Edit Song Metadata").font(.title2.bold()) + Grid(alignment:.leading,horizontalSpacing:12,verticalSpacing:10) { + GridRow { Text("Title");TextField("Title",text:$title) } + GridRow { Text("Artist");TextField("Artist",text:$artist) } + GridRow { Text("Album");TextField("Album",text:$album) } + GridRow { Text("Label");TextField("Label / publisher",text:$label) } + GridRow { Text("Rating");GoldRating(rating:$rating) } + }.textFieldStyle(.roundedBorder) + Text("Saved to your library. Embedded audio-file tags are not changed, and re-importing will preserve your library edits.").font(.caption).foregroundStyle(.secondary) + Text(URL(fileURLWithPath:song.audio).lastPathComponent).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle).help(song.audio) + if let error { Text(error).foregroundStyle(.red).font(.callout) } + HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction);Button("Save") { save() }.keyboardShortcut(.defaultAction).disabled(title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) } + }.padding(24).frame(width:500).background(Classic.frame).preferredColorScheme(.dark) + .onAppear { title = song.title;artist = song.artist;album = song.album;label = song.label;rating = song.rating } + } + private func save() { do { try model.updateMetadata(song,title:title,artist:artist,album:album,label:label,rating:rating);dismiss() } catch { self.error = error.localizedDescription } } +} +private struct MusicStoresPane: View { + @ObservedObject var model: AppModel + @Binding var selected: Int64? + @Environment(\.openURL) private var openURL + @State private var editor = false + @State private var editing: MusicStore? + @State private var search = "" + @State private var deleting: Int64? + var body: some View { + VStack(alignment:.leading,spacing:14) { + HStack { Text("Music Stores").font(.headline);Spacer();Button("Add Store…") { editing = nil;editor = true } } + Text("Define websites and optional catalogue-search URLs. Purchases and subscriptions are handled by the store in your browser.").font(.callout).foregroundStyle(.secondary) + HStack { TextField("Search store catalogue",text:$search).textFieldStyle(.roundedBorder);Text("{query} in a store’s search URL is replaced with this text.").font(.caption).foregroundStyle(.secondary) } + ScrollView { + VStack(alignment:.leading,spacing:12) { + ForEach(model.musicStores) { store in + VStack(alignment:.leading,spacing:8) { + HStack { Image(systemName:"bag.fill").foregroundStyle(.green);Text(store.name).font(.headline);Spacer() + Button("Open Store") { open(store,query:"") } + Button("Search") { open(store,query:search) }.disabled(search.isEmpty || store.searchTemplate.isEmpty) + Button("Edit") { editing = store;editor = true } + Button("Delete",role:.destructive) { deleting = store.id } + } + Text(store.url).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + }.padding(12).background(selected == store.id ? Color.blue.opacity(0.15) : Color.white.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = store.id } + } + if model.musicStores.isEmpty { Text("No stores configured. Add a store name and website address to get started.").foregroundStyle(.secondary).padding(.top,20) } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + .sheet(isPresented:$editor) { MusicStoreEditor(model:model,store:editing) } + .confirmationDialog("Delete this store definition?",isPresented:Binding(get:{ deleting != nil },set:{ if !$0 { deleting = nil } })) { + Button("Delete",role:.destructive) { if let deleting { model.deleteStore(deleting) };deleting = nil } + } + } + private func open(_ store: MusicStore,query: String) { + do { openURL(try store.destination(query:query)) } catch { model.error = error.localizedDescription } + } +} +private struct MusicStoreEditor: View { + @ObservedObject var model: AppModel + let store: MusicStore? + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var url = "" + @State private var search = "" + @State private var error: String? + var body: some View { + VStack(alignment:.leading,spacing:14) { + Text(store == nil ? "Add Music Store" : "Edit Music Store").font(.title2.bold()) + TextField("Store name",text:$name).textFieldStyle(.roundedBorder) + TextField("Website URL — https://example.com",text:$url).textFieldStyle(.roundedBorder) + TextField("Optional search URL — https://example.com/search?q={query}",text:$search).textFieldStyle(.roundedBorder) + Text("Use {query} where the search words belong. Only http:// and https:// websites are accepted.").font(.caption).foregroundStyle(.secondary) + if let error { Text(error).foregroundStyle(.red).font(.callout) } + HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction);Button("Save") { save() }.keyboardShortcut(.defaultAction).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty || url.isEmpty) } + }.padding(24).frame(width:500).background(Classic.frame).preferredColorScheme(.dark) + .onAppear { name = store?.name ?? "";url = store?.url ?? "";search = store?.searchTemplate ?? "" } + } + private func save() { do { try model.saveStore(.init(id:store?.id ?? 0,name:name,url:url,searchTemplate:search));dismiss() } catch { self.error = error.localizedDescription } } +} +private struct SingerSongsSheet: View { + @ObservedObject var model: AppModel + let singer: PersonPlace + var tonight = false + @Environment(\.dismiss) private var dismiss + @State private var search = "" + @State private var importing = false + var body: some View { + VStack(alignment:.leading,spacing:14) { + Text(tonight ? "Tonight for \(singer.name)" : "Songs for \(singer.name)").font(.title2.bold()) + Text(tonight ? "Tick songs for this venue’s Tonight list. They stay here after playback and when switching singers." : "Tick songs to add them to this singer’s repertoire.").font(.callout).foregroundStyle(.secondary) + TextField("Search library",text:$search).textFieldStyle(.roundedBorder) + ScrollView { + LazyVStack(alignment:.leading,spacing:12) { + ForEach(model.songs.filter { search.isEmpty || $0.title.localizedCaseInsensitiveContains(search) }) { song in + HStack { + Toggle(song.title,isOn:Binding(get:{ tonight ? model.tonightSongIDs[singer.id]?.contains(song.id) == true : model.singerSongIDs[singer.id]?.contains(song.id) == true },set:{ included in + if tonight { if included { model.addToTonight(song.id,singer:singer.id) } else { model.removeFromTonight(song.id,singer:singer.id) } } + else { model.assign(song.id,to:singer.id,included:included) } + })).toggleStyle(.checkbox) + Spacer() + Button { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } label: { GoldStar(filled:model.favourites[singer.id]?.contains(song.id) == true,size:15) }.buttonStyle(.plain).help("Toggle favourite") + } + } + if model.songs.isEmpty { Text("Your library is empty. Import audio or MP3+G below.").foregroundStyle(.secondary) } + }.frame(maxWidth:.infinity,alignment:.leading) + }.frame(height:270) + HStack { Button("Import Songs…") { importing = true };Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) } + }.padding(24).frame(width:520).background(Classic.frame).preferredColorScheme(.dark) + .fileImporter(isPresented:$importing,allowedContentTypes:[.audio,.zip,UTType(filenameExtension:"cdg") ?? .data],allowsMultipleSelection:true) { result in + switch result { case .success(let urls):model.importFiles(urls);case .failure(let error):model.error = error.localizedDescription } + } + } +} +private struct AddEntitySheet: View { + @ObservedObject var model: AppModel + let kind: EntityKind + let saved: (Int64) -> Void + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var error: String? + @State private var venue: Int64? + @State private var songs = Set() + @State private var search = "" + @FocusState private var focused: Bool + var body: some View { + VStack(alignment:.leading,spacing:16) { + Text(kind == .singer ? "New Singer + Songs" : "New Venue").font(.title2.bold()) + TextField("\(kind.title) name",text:$name).textFieldStyle(.roundedBorder).focused($focused).onSubmit(save) + if kind == .singer { + Picker("Venue",selection:$venue) { Text("Choose a venue…").tag(nil as Int64?);ForEach(model.venues) { Text($0.name).tag(Optional($0.id)) } } + Text(venue == nil ? "Create a venue first, then add a singer to it." : "This singer will join the selected venue and become active.").font(.caption).foregroundStyle(.secondary) + Text("Add songs now (optional)").font(.headline) + TextField("Search library",text:$search).textFieldStyle(.roundedBorder) + ScrollView { + LazyVStack(alignment:.leading,spacing:10) { + ForEach(model.songs.filter { search.isEmpty || $0.title.localizedCaseInsensitiveContains(search) }) { song in + Toggle(song.title,isOn:Binding(get:{ songs.contains(song.id) },set:{ if $0 { songs.insert(song.id) } else { songs.remove(song.id) } })).toggleStyle(.checkbox) + } + if model.songs.isEmpty { Text("No library songs yet. You can add songs from the singer’s + button after importing.").font(.callout).foregroundStyle(.secondary) } + }.frame(maxWidth:.infinity,alignment:.leading) + }.frame(height:190) + } + if let error { Text(error).foregroundStyle(.red).font(.callout) } + HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Button(kind == .singer ? "Add Singer & Songs" : "Add Venue",action:save).keyboardShortcut(.defaultAction) + .disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty || (kind == .singer && venue == nil)) + } + }.padding(24).frame(width:kind == .singer ? 500 : 360).background(Classic.frame).preferredColorScheme(.dark) + .onAppear { focused = true;venue = model.currentVenue ?? model.venues.first?.id;songs = model.newSingerSongs } + } + private func save() { + do { + let id: Int64 + if kind == .singer { guard let venue else { throw LibraryError.message("Choose a venue") };id = try model.createSinger(name:name,venue:venue,songs:songs) } + else { id = try model.createEntity(kind,name:name) } + saved(id);dismiss() + } catch { self.error = error.localizedDescription } + } +} +private struct TransportView: View { + @ObservedObject var player: Player + @Binding var search: String + let hasSelection: Bool + let play: () -> Void + let next: () -> Void + let display: () -> Void + var body: some View { + HStack(alignment: .top, spacing: 28) { + VStack(spacing: 15) { + HStack(spacing: 7) { + SilverButton(symbol: "backward.fill", label: "Back 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time - 10) } + SilverButton(symbol: player.playing ? "pause.fill" : "play.fill", label: player.playing ? "Pause" : "Play", size: 49, enabled: hasSelection || player.duration > 0) { + if player.duration > 0 { player.toggle() } else { play() } + } + SilverButton(symbol: "stop", label: "Stop", size: 46, enabled: player.duration > 0) { player.stop() } + SilverButton(symbol: "forward.fill", label: "Forward 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time + 10) } + SilverButton(symbol: "forward.end.fill", label: "Play next song", size: 26, enabled: hasSelection, action: next) + } + HStack(spacing: 9) { + Image(systemName: "speaker.fill").font(.system(size: 11)) + Slider(value: $player.volume, in: 0...1).controlSize(.small).tint(.gray).accessibilityLabel("Volume") + Image(systemName: "speaker.wave.3.fill").font(.system(size: 12)) + }.frame(width: 180) + }.frame(width: 220) + NowPlayingWaveform(player: player).padding(.top, 4) + HStack(spacing: 12) { + TextField("Search", text: $search).textFieldStyle(.plain).font(.system(size: 16)) + .padding(.horizontal, 8).frame(width: 145, height: 28).background(Color(white: 0.07), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).stroke(.white.opacity(0.1))) + SilverButton(symbol: "eye", label: "Open video window", size: 48, action: display) + }.padding(.top, 15) + }.foregroundStyle(.white).frame(height: 130) + } +} +private struct NowPlayingWaveform: View { + @ObservedObject var player: Player + @State private var mixer = false + @State private var zoom: Double = 1 + @State private var hoverX: CGFloat? + @State private var scrubStart: Double? + @State private var resumeAfterScrub = false + @State private var scrubSong: Int64? + private var span: Double { max(0.001,player.duration/zoom) } + private var windowStart: Double { scrubStart ?? min(max(0,player.time-span/2),max(0,player.duration-span)) } + private var progress: Double { player.duration > 0 ? min(1,max(0,(player.time-windowStart)/span)) : 0 } + private func clock(_ value: Double) -> String { let n = max(0,Int(value));return String(format:"%d:%02d",n/60,n%60) } + private func preciseClock(_ value: Double) -> String { String(format:"%d:%05.2f",max(0,Int(value))/60,max(0,value).truncatingRemainder(dividingBy:60)) } + private func bandColor(_ index: Int) -> Color { + guard player.waveformBands.indices.contains(index) else { return .cyan } + let band = player.waveformBands[index] + let largest = max(0.000001,max(band.low,max(band.mid,band.high))) + let low = Double(band.low/largest),mid = Double(band.mid/largest),high = Double(band.high/largest) + return Color(red:min(1,pow(low,1.2)+mid*0.12+high*0.6),green:min(1,low*0.08+pow(mid,1.5)*0.97+high*0.1),blue:min(1,low*0.07+mid*0.22+high)) + } + var body: some View { + VStack(alignment:.leading,spacing:5) { + Text(player.duration > 0 ? player.title : "Ready to play").font(.system(size:13,weight:.semibold)).foregroundStyle(Color(white:0.94)) + .lineLimit(1).truncationMode(.middle).frame(maxWidth:.infinity,alignment:.leading).help(player.title) + GeometryReader { geometry in + Canvas { context,size in + let top: CGFloat = 10,plotHeight = size.height-top,mid = top+plotHeight/2 + var centre = Path();centre.move(to:CGPoint(x:0,y:mid));centre.addLine(to:CGPoint(x:size.width,y:mid)) + context.stroke(centre,with:.color(.white.opacity(0.13)),lineWidth:0.5) + if player.duration > 0 { + let step = [0.1,0.25,0.5,1.0,2,5,10,15,30,60,120,300,600,1800,3600].first { $0 >= span/5 } ?? 3600 + let first = floor(windowStart/(step/4))*(step/4) + for t in stride(from:first,through:min(player.duration,windowStart+span),by:step/4) { + let x = size.width*(t-windowStart)/span + if x < 0 { continue } + let major = abs(t/step-(t/step).rounded()) < 0.001 + var tick = Path();tick.move(to:CGPoint(x:x,y:top));tick.addLine(to:CGPoint(x:x,y:size.height)) + context.stroke(tick,with:.color(major ? .yellow.opacity(0.65) : .white.opacity(0.16)),lineWidth:major ? 0.8 : 0.5) + if major && x < size.width-25 { + context.draw(Text(span < 10 ? preciseClock(t) : clock(t)).font(.system(size:8,design:.monospaced)).foregroundColor(.gray),at:CGPoint(x:x+3,y:3),anchor:.leading) + } + } + } + if !player.waveform.isEmpty { + let count = max(1,Int(size.width/2)),step = size.width/CGFloat(count) + for bar in 0.. 0,geometry.size.width > 0 else { return } + if scrubStart == nil { + scrubStart = windowStart;scrubSong = player.currentSongID;resumeAfterScrub = player.playing + if player.playing { player.toggle() } + } + guard scrubSong == player.currentSongID else { return } + hoverX = value.location.x + let target = windowStart+Double(min(1,max(0,value.location.x/geometry.size.width)))*span + if abs(target-player.time) > 0.02 { player.seek(target) } + }.onEnded { value in + if scrubStart == nil { + if player.duration > 0 { player.seek(windowStart+Double(min(1,max(0,value.location.x/max(1,geometry.size.width))))*span) } + return + } + let target = windowStart+Double(min(1,max(0,value.location.x/max(1,geometry.size.width))))*span + if scrubSong == player.currentSongID { + player.seek(target) + if resumeAfterScrub && !player.playing && target < player.duration-0.01 { player.toggle() } + } + scrubStart = nil;resumeAfterScrub = false;scrubSong = nil + }).help("Hover for exact time. Drag to scrub; playback resumes on release. Zoom for fine positioning.") + .accessibilityLabel("Audio waveform").accessibilityValue("\(clock(player.time)) of \(clock(player.duration))") + .accessibilityAdjustableAction { direction in if direction == .increment { player.seek(player.time+5) } else if direction == .decrement { player.seek(player.time-5) } } + }.frame(height:60) + HStack(spacing:5) { + Circle().fill(player.playing ? Color.green : Color.orange).frame(width:4,height:4) + Text(player.duration == 0 ? "NO TRACK LOADED" : player.playing ? "PLAYING" : "PAUSED").tracking(1) + if player.waveformUnavailable { Text("· Waveform unavailable").foregroundStyle(.secondary) } + Spacer(minLength:0) + Button { zoom = max(1,zoom/2) } label: { Image(systemName:"minus.magnifyingglass") }.buttonStyle(.plain).disabled(zoom <= 1).help("Zoom out") + Button { zoom = min(32,zoom*2) } label: { Image(systemName:"plus.magnifyingglass") }.buttonStyle(.plain).disabled(zoom >= 32).help("Zoom in for precise scrubbing") + Menu("\(Int(zoom))×") { ForEach([1.0,2,4,8,16,32],id:\.self) { value in Button("\(Int(value))× zoom") { zoom = value } } }.menuStyle(.borderlessButton).frame(width:34).help("Waveform zoom around the playhead") + Menu { Button("Re-analyse Waveform") { player.reanalyseWaveform() }.disabled(player.duration == 0) + Text(player.waveformLoading ? "Analysing…" : player.waveformFromCache ? "Loaded saved waveform" : player.waveformSaved ? "Analysis saved" : "No saved analysis") + } label: { Image(systemName:"waveform") }.menuStyle(.borderlessButton).frame(width:20).help("Waveform cache and re-analysis") + Button("EQ / TONE") { mixer.toggle() }.buttonStyle(.plain).foregroundStyle(.white.opacity(0.85)) + .padding(.horizontal,6).padding(.vertical,2).background(.white.opacity(0.08),in:RoundedRectangle(cornerRadius:3)) + .popover(isPresented:$mixer) { ToneMixerView(player:player) } + Text("\(clock(player.time)) / \(clock(player.duration))").monospacedDigit().padding(.leading,5) + }.font(.system(size:9,weight:.medium)).foregroundStyle(Color(white:0.6)) + }.padding(.horizontal,12).padding(.vertical,9).frame(maxWidth:.infinity).frame(height:120) + .background(Color(red:0.025,green:0.028,blue:0.035)).overlay(Rectangle().strokeBorder(Color(white:0.24),lineWidth:1)) + } +} +private struct ToneKnob: View { + let title: String + @Binding var value: Double + let range: ClosedRange + var filter = false + @State private var initial: Double? + private var fraction: Double { min(1,max(0,(value-range.lowerBound)/(range.upperBound-range.lowerBound))) } + private var display: String { + if filter { return abs(value) < 0.01 ? "OFF" : value < 0 ? "LOW PASS" : "HIGH PASS" } + return String(format:"%+.1f dB",value) + } + var body: some View { + VStack(spacing:4) { + ZStack { + Circle().trim(from:0,to:0.75).stroke(Color(white:0.21),style:StrokeStyle(lineWidth:4,lineCap:.round)).rotationEffect(.degrees(135)) + Circle().trim(from:0,to:0.75*fraction).stroke(Color(white:0.48),style:StrokeStyle(lineWidth:3,lineCap:.round)).rotationEffect(.degrees(135)) + Circle().fill(LinearGradient(colors:[Color(white:0.19),Color(white:0.09)],startPoint:.top,endPoint:.bottom)).padding(6).overlay(Circle().stroke(.black.opacity(0.65),lineWidth:1.5).padding(6)) + Capsule().fill(.white).frame(width:2,height:12).offset(y:-13).rotationEffect(.degrees(-135+270*fraction)) + }.frame(width:50,height:50).contentShape(Circle()) + .gesture(DragGesture(minimumDistance:0).onChanged { gesture in + if initial == nil { initial = value } + value = min(range.upperBound,max(range.lowerBound,(initial ?? value)-Double(gesture.translation.height)/130*(range.upperBound-range.lowerBound))) + }.onEnded { _ in initial = nil }) + .simultaneousGesture(TapGesture(count:2).onEnded { value = 0 }) + .accessibilityElement().accessibilityLabel(title).accessibilityValue(display) + .accessibilityAdjustableAction { direction in + let change = (range.upperBound-range.lowerBound)/24 + value = min(range.upperBound,max(range.lowerBound,value+(direction == .increment ? change : -change))) + }.help("Drag up/down to adjust. Double-click to reset.") + Text(title).font(.system(size:10,weight:.bold)).foregroundStyle(Color(white:0.6)) + Text(display).font(.system(size:8,design:.monospaced)).foregroundStyle(Color(white:0.45)) + } + } +} +private struct StereoMeter: View { + @ObservedObject var player: Player + private func normal(_ level: Float) -> Double { max(0,min(1,(20*log10(Double(max(level,0.000001)))+60)/60)) } + var body: some View { + HStack(spacing:3) { + ForEach(0..<2) { channel in + VStack(spacing:2) { + ForEach((0..<24).reversed(),id:\.self) { segment in + let on = Double(segment)/24 < normal(channel == 0 ? player.leftLevel : player.rightLevel) + Rectangle().fill(on ? (segment > 21 ? Color.red : segment > 17 ? Color.yellow : Color.green) : Color.black.opacity(0.5)) + } + } + } + }.frame(width:23,height:125).accessibilityLabel("Stereo output levels") + } +} +private struct MixerFader: View { + @Binding var value: Double + var body: some View { + GeometryReader { geometry in + let travel = geometry.size.height-22 + ZStack(alignment:.top) { + VStack { ForEach(0..<7) { _ in Rectangle().fill(.white.opacity(0.09)).frame(height:1);Spacer(minLength:0) } }.padding(.vertical,11) + Capsule().fill(.black.opacity(0.8)).frame(width:5).padding(.vertical,8) + RoundedRectangle(cornerRadius:5).fill(LinearGradient(colors:[Color(white:0.45),Color(white:0.23)],startPoint:.top,endPoint:.bottom)) + .frame(width:45,height:20).overlay(Rectangle().fill(.white.opacity(0.15)).frame(width:18,height:1)) + .offset(y:(1-value)*travel) + }.frame(maxWidth:.infinity).contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance:0).onChanged { value = min(1,max(0,1-Double(($0.location.y-11)/travel))) }) + }.frame(width:56,height:135).accessibilityLabel("Master volume").accessibilityValue("\(Int(value*100)) percent") + .accessibilityAdjustableAction { direction in value = min(1,max(0,value+(direction == .increment ? 0.05 : -0.05))) } + } +} +private struct ToneMixerView: View { + @ObservedObject var player: Player + var body: some View { + VStack(spacing:14) { + HStack { Text("TONE / MIXER").font(.system(size:12,weight:.bold));Spacer();Toggle("EQ",isOn:$player.toneEnabled).toggleStyle(.switch).controlSize(.mini) } + HStack(alignment:.top,spacing:28) { + VStack(spacing:8) { + ToneKnob(title:"HIGH",value:$player.highGain,range:-12...12) + ToneKnob(title:"MID",value:$player.midGain,range:-12...12) + ToneKnob(title:"LOW",value:$player.lowGain,range:-12...12) + } + VStack(spacing:15) { + ToneKnob(title:"FILTER",value:$player.filter,range:-1...1,filter:true) + HStack(spacing:20) { StereoMeter(player:player);MixerFader(value:$player.volume) } + Text(player.volume <= 0 ? "−∞ dB" : String(format:"%+.1f dB",20*log10(player.volume))).font(.system(size:10,design:.monospaced)).foregroundStyle(.secondary) + } + }.padding(.horizontal,15) + HStack { Text("Drag knobs · Double-click to reset").font(.system(size:9)).foregroundStyle(.secondary);Spacer();Button("Reset EQ") { player.resetTone() }.controlSize(.small) } + }.padding(18).frame(width:270).background(Color(white:0.105)).preferredColorScheme(.dark) + } +} +private struct PlaybackSettings: View { + @ObservedObject var player: Player + @ObservedObject var model: AppModel + let selectedSong: Int64? + @State private var personalRating = 0 + var body: some View { + HStack(spacing:10) { + Text("Key") + Picker("Key",selection:$player.pitch) { ForEach(-12..<13) { Text(String(format:"%+d",$0)).tag(Double($0)) } }.labelsHidden().frame(width:70) + Text("Tempo") + Slider(value:$player.tempo,in:0.5...1.5,step:0.05).frame(width:85) + Text(String(format:"%.0f%%",player.tempo*100)).monospacedDigit().frame(width:40) + Text("Rating") + GoldRating(rating:$personalRating).frame(width:88).help("Singer-specific song rating") + Button("Save for Singer") { + if let song = player.currentSongID ?? selectedSong { model.savePreference(song:song,value:.init(pitch:player.pitch,tempo:player.tempo,rating:personalRating)) } + }.disabled(model.selectedSinger == nil || (player.currentSongID == nil && selectedSong == nil)) + Menu { + Button("Reset key / tempo") { player.pitch = 0; player.tempo = 1 } + ForEach([-1.0,-0.5,0,0.5,1.0],id:\.self) { offset in Button(String(format:"Graphics offset %+.1fs",offset)) { player.graphicsOffset = offset } } + } label: { Image(systemName:"slider.horizontal.3") }.menuStyle(.borderlessButton).frame(width:22).help("Playback settings") + }.font(.system(size:11)) + .onChange(of:model.selectedSinger) { _ in loadRating() } + .onChange(of:player.currentSongID) { _ in loadRating() } + .onChange(of:selectedSong) { _ in loadRating() } + } + private func loadRating() { if let song = player.currentSongID ?? selectedSong { personalRating = model.preference(song).rating } else { personalRating = 0 } } +} +private struct SeekBar: View { + @ObservedObject var player: Player + private func clock(_ seconds: Double) -> String { let n = Int(seconds); return String(format: "%d:%02d", n / 60, n % 60) } + var body: some View { + HStack(spacing: 10) { + Text(clock(player.time)) + Slider(value: Binding(get: { player.time }, set: { player.seek($0) }), in: 0...max(1, player.duration)) + .controlSize(.mini).tint(.gray).disabled(player.duration == 0).accessibilityLabel("Playback position") + Text(clock(player.duration)) + }.font(.system(size: 10, design: .monospaced)).foregroundStyle(.white.opacity(0.65)) + } +} +private struct RepeatButton: View { + @ObservedObject var player: Player + var body: some View { + Button { player.repeating.toggle() } label: { + Image(systemName: "repeat").font(.system(size: 18, weight: .semibold)) + .foregroundStyle(player.repeating ? Color.blue : Color(white: 0.25)).frame(width: 32, height: 24) + }.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off") + } +} diff --git a/backups/before-tonight-drag-20261103-094630/KJamsClone/Library.swift b/backups/before-tonight-drag-20261103-094630/KJamsClone/Library.swift new file mode 100644 index 0000000..5c7ccae --- /dev/null +++ b/backups/before-tonight-drag-20261103-094630/KJamsClone/Library.swift @@ -0,0 +1,317 @@ +import Foundation +import SQLite3 + +struct Song: Identifiable, Hashable { + var id: Int64 + var title: String + var audio: String + var cdg: String? + var rating: Int + var plays: Int + var artist: String = "" + var album: String = "" + var label: String = "" + var duration: Double = 0 +} +struct MusicStore: Identifiable, Hashable { + var id: Int64 + var name: String + var url: String + var searchTemplate: String + func destination(query: String = "") throws -> URL { + let query = query.trimmingCharacters(in:.whitespacesAndNewlines) + let encoded = query.addingPercentEncoding(withAllowedCharacters:CharacterSet(charactersIn:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")) ?? "" + let value = query.isEmpty || searchTemplate.isEmpty ? url : searchTemplate.replacingOccurrences(of:"{query}",with:encoded) + guard let result = URL(string:value), ["http","https"].contains(result.scheme?.lowercased() ?? ""), result.host?.isEmpty == false, result.user == nil, result.password == nil else { throw LibraryError.message("Use a valid http:// or https:// store URL without credentials") } + return result + } +} +struct PersonPlace: Identifiable, Hashable { var id: Int64; var name: String } +struct SongPreference: Equatable { + var pitch: Double = 0 + var tempo: Double = 1 + var rating: Int = 0 +} +struct Performance: Identifiable { + var id: Int64 + var song: String + var singer: String + var venue: String + var date: String + var singerID: Int64? + var songID: Int64? +} +struct QueueEntry: Identifiable { + var id: Int64 + var songID: Int64 + var singerID: Int64? + var title: String + var singer: String +} +enum LibraryError: LocalizedError { + case message(String) + var errorDescription: String? { if case .message(let s) = self { return s }; return nil } +} +final class Library { + private var db: OpaquePointer? + init(url: URL) throws { + guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw LibraryError.message("Cannot open library") } + try execute("PRAGMA foreign_keys=ON") + try execute("CREATE TABLE IF NOT EXISTS songs(id INTEGER PRIMARY KEY,title TEXT NOT NULL,audio TEXT UNIQUE NOT NULL,cdg TEXT,rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),plays INTEGER NOT NULL DEFAULT 0)") + try execute("CREATE TABLE IF NOT EXISTS singers(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS venues(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS history(id INTEGER PRIMARY KEY,song INTEGER REFERENCES songs(id),singer INTEGER REFERENCES singers(id),venue INTEGER REFERENCES venues(id),played TEXT DEFAULT CURRENT_TIMESTAMP)") + // Additive migration: preserve older libraries and infer existing memberships from history. + for column in ["artist", "album", "label"] { + if try !columns("songs").contains(column) { try execute("ALTER TABLE songs ADD COLUMN \(column) TEXT NOT NULL DEFAULT ''") } + } + try execute("CREATE TABLE IF NOT EXISTS venue_singers(venue INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,PRIMARY KEY(venue,singer))") + if try schemaVersion() < 3 { try execute("INSERT OR IGNORE INTO venue_singers SELECT DISTINCT venue,singer FROM history WHERE venue IS NOT NULL AND singer IS NOT NULL") } + try execute("CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT)") + try execute("CREATE TABLE IF NOT EXISTS preferences(song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5),rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),PRIMARY KEY(song,singer))") + try execute("CREATE TABLE IF NOT EXISTS queue(id INTEGER PRIMARY KEY,venue INTEGER REFERENCES venues(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER REFERENCES singers(id) ON DELETE CASCADE,position INTEGER NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS playlists(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS playlist_songs(playlist INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(playlist,song))") + try execute("CREATE TABLE IF NOT EXISTS singer_songs(singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(singer,song))") + if try schemaVersion() < 4 { + try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,MIN(id) FROM history WHERE singer IS NOT NULL AND song IS NOT NULL GROUP BY singer,song") + try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,0 FROM preferences") + } + if try !columns("songs").contains("duration") { try execute("ALTER TABLE songs ADD COLUMN duration REAL NOT NULL DEFAULT 0") } + if try !columns("singer_songs").contains("favourite") { try execute("ALTER TABLE singer_songs ADD COLUMN favourite INTEGER NOT NULL DEFAULT 0 CHECK(favourite IN (0,1))") } + if try schemaVersion() < 5 { try execute("UPDATE singer_songs SET favourite=1 WHERE EXISTS(SELECT 1 FROM preferences WHERE preferences.singer=singer_songs.singer AND preferences.song=singer_songs.song AND preferences.rating>=4)") } + try execute("CREATE TABLE IF NOT EXISTS song_settings(song INTEGER PRIMARY KEY REFERENCES songs(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5))") + try execute("CREATE TABLE IF NOT EXISTS music_stores(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL,url TEXT NOT NULL,search_template TEXT NOT NULL DEFAULT '')") + if try !columns("songs").contains("metadata_edited") { try execute("ALTER TABLE songs ADD COLUMN metadata_edited INTEGER NOT NULL DEFAULT 0") } + try execute("CREATE TABLE IF NOT EXISTS singer_tonight(venue INTEGER NOT NULL,singer INTEGER NOT NULL,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(venue,singer,song),FOREIGN KEY(venue,singer) REFERENCES venue_singers(venue,singer) ON DELETE CASCADE)") + if try schemaVersion() < 7 { + try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) SELECT queue.venue,queue.singer,queue.song,MIN(queue.position) FROM queue JOIN venue_singers ON venue_singers.venue=queue.venue AND venue_singers.singer=queue.singer WHERE queue.singer IS NOT NULL GROUP BY queue.venue,queue.singer,queue.song") + } + try execute("PRAGMA user_version=7") + } + deinit { sqlite3_close(db) } + private func statement(_ sql: String, _ values: [String?]) throws -> OpaquePointer { + var s: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &s, nil) == SQLITE_OK, let s else { throw error() } + for (i,v) in values.enumerated() { + let code = v.map { sqlite3_bind_text(s, Int32(i + 1), $0, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) } ?? sqlite3_bind_null(s, Int32(i + 1)) + if code != SQLITE_OK { sqlite3_finalize(s); throw error() } + } + return s + } + private func error() -> LibraryError { .message(String(cString: sqlite3_errmsg(db))) } + func execute(_ sql: String, _ values: [String?] = []) throws { + let s = try statement(sql, values); defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_DONE else { throw error() } + } + func songs() throws -> [Song] { + let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label,duration FROM songs ORDER BY title COLLATE NOCASE", []); defer { sqlite3_finalize(s) } + var result = [Song]() + while true { + let code = sqlite3_step(s); if code == SQLITE_DONE { break }; guard code == SQLITE_ROW else { throw error() } + result.append(Song(id: sqlite3_column_int64(s,0), title: String(cString: sqlite3_column_text(s,1)), audio: String(cString: sqlite3_column_text(s,2)), cdg: sqlite3_column_text(s,3).map { String(cString:$0) }, rating: Int(sqlite3_column_int(s,4)), plays: Int(sqlite3_column_int(s,5)), artist: text(s,6), album: text(s,7), label: text(s,8),duration:sqlite3_column_double(s,9))) + } + return result + } + func add(title: String, audio: String, cdg: String?, artist: String = "", album: String = "", label: String = "", duration: Double = 0) throws { + try execute("INSERT INTO songs(title,audio,cdg,artist,album,label,duration) VALUES(?,?,?,?,?,?,?) ON CONFLICT(audio) DO UPDATE SET cdg=COALESCE(excluded.cdg,songs.cdg),artist=CASE WHEN songs.metadata_edited=0 AND excluded.artist!='' THEN excluded.artist ELSE songs.artist END,album=CASE WHEN songs.metadata_edited=0 AND excluded.album!='' THEN excluded.album ELSE songs.album END,label=CASE WHEN songs.metadata_edited=0 AND excluded.label!='' THEN excluded.label ELSE songs.label END,duration=CASE WHEN excluded.duration>0 THEN excluded.duration ELSE songs.duration END", [title,audio,cdg,artist,album,label,String(duration)]) + } + func rate(_ id: Int64, _ rating: Int) throws { try execute("UPDATE songs SET rating=? WHERE id=?", [String(rating),String(id)]) } + func entities(_ table: String) throws -> [PersonPlace] { + precondition(["singers","venues","playlists"].contains(table)) + let s = try statement("SELECT id,name FROM \(table) ORDER BY name", []); defer { sqlite3_finalize(s) } + var rows = [PersonPlace]() + while sqlite3_step(s) == SQLITE_ROW { rows.append(.init(id: sqlite3_column_int64(s,0), name: String(cString: sqlite3_column_text(s,1)))) } + return rows + } + func addEntity(_ table: String, name: String) throws { + precondition(["singers","venues","playlists"].contains(table)) + let name = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { throw LibraryError.message("Enter a name") } + try execute("INSERT OR IGNORE INTO \(table)(name) VALUES(?)", [name]) + } + func record(_ song: Int64, singer: Int64?, venue: Int64?) throws { + try execute("BEGIN IMMEDIATE") + do { + if let singer { try assignSong(song,singer:singer) } + try execute("INSERT INTO history(song,singer,venue) VALUES(?,?,?)", [String(song),singer.map(String.init),venue.map(String.init)]) + try execute("UPDATE songs SET plays=plays+1 WHERE id=?", [String(song)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK"); throw error } + } + + private func text(_ s: OpaquePointer, _ index: Int32) -> String { sqlite3_column_text(s,index).map { String(cString:$0) } ?? "" } + private func schemaVersion() throws -> Int { + let s = try statement("PRAGMA user_version",[]); defer { sqlite3_finalize(s) } + return sqlite3_step(s) == SQLITE_ROW ? Int(sqlite3_column_int(s,0)) : 0 + } + private func columns(_ table: String) throws -> Set { + let s = try statement("PRAGMA table_info(\(table))", []); defer { sqlite3_finalize(s) } + var result = Set(); while sqlite3_step(s) == SQLITE_ROW { result.insert(text(s,1)) }; return result + } + func members(of venue: Int64) throws -> [PersonPlace] { + let s = try statement("SELECT singers.id,singers.name FROM singers JOIN venue_singers ON singers.id=venue_singers.singer WHERE venue=? ORDER BY name", [String(venue)]); defer { sqlite3_finalize(s) } + var result = [PersonPlace](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1))) }; return result + } + func setMembership(singer: Int64, venue: Int64, included: Bool) throws { + try execute(included ? "INSERT OR IGNORE INTO venue_singers(venue,singer) VALUES(?,?)" : "DELETE FROM venue_singers WHERE venue=? AND singer=?", [String(venue),String(singer)]) + if !included { try execute("DELETE FROM queue WHERE venue=? AND singer=?",[String(venue),String(singer)]) } + } + func currentVenue() throws -> Int64? { + let s = try statement("SELECT venues.id FROM venues JOIN settings ON CAST(settings.value AS INTEGER)=venues.id WHERE settings.key='currentVenue'", []); defer { sqlite3_finalize(s) } + return sqlite3_step(s) == SQLITE_ROW ? sqlite3_column_int64(s,0) : nil + } + func selectVenue(_ id: Int64?) throws { + if let id { + guard try entities("venues").contains(where: { $0.id == id }) else { throw LibraryError.message("Venue no longer exists") } + } + try execute("INSERT INTO settings(key,value) VALUES('currentVenue',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",[id.map(String.init)]) + } + func rename(_ table: String, id: Int64, name: String) throws { + precondition(["singers","venues","playlists"].contains(table)) + let name = name.trimmingCharacters(in:.whitespacesAndNewlines) + guard !name.isEmpty else { throw LibraryError.message("Enter a name") } + try execute("UPDATE \(table) SET name=? WHERE id=?",[name,String(id)]) + } + func deleteEntity(_ table: String, id: Int64) throws { + precondition(["singers","venues","playlists"].contains(table)) + try execute("BEGIN IMMEDIATE") + do { + if table == "singers" { try execute("UPDATE history SET singer=NULL WHERE singer=?",[String(id)]) } + if table == "venues" { + try execute("UPDATE history SET venue=NULL WHERE venue=?",[String(id)]) + try execute("DELETE FROM settings WHERE key='currentVenue' AND value=?",[String(id)]) + } + try execute("DELETE FROM \(table) WHERE id=?",[String(id)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK"); throw error } + } + func preference(song: Int64, singer: Int64) throws -> SongPreference { + let s = try statement("SELECT pitch,tempo,rating FROM preferences WHERE song=? AND singer=?",[String(song),String(singer)]); defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_ROW else { return try defaultPreference(song:song) } + return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:Int(sqlite3_column_int(s,2))) + } + func savePreference(song: Int64, singer: Int64, value: SongPreference) throws { + try execute("INSERT INTO preferences(song,singer,pitch,tempo,rating) VALUES(?,?,?,?,?) ON CONFLICT(song,singer) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo,rating=excluded.rating",[String(song),String(singer),String(value.pitch),String(value.tempo),String(value.rating)]) + } + func history(venue: Int64?, singer: Int64? = nil) throws -> [Performance] { + let s = try statement("SELECT history.id,COALESCE(songs.title,'Deleted song'),COALESCE(singers.name,'—'),COALESCE(venues.name,'—'),played,history.singer,history.song FROM history LEFT JOIN songs ON songs.id=history.song LEFT JOIN singers ON singers.id=history.singer LEFT JOIN venues ON venues.id=history.venue WHERE (? IS NULL OR history.venue=?) AND (? IS NULL OR history.singer=?) ORDER BY history.id DESC",[venue.map(String.init),venue.map(String.init),singer.map(String.init),singer.map(String.init)]); defer { sqlite3_finalize(s) } + var result = [Performance](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),song:text(s,1),singer:text(s,2),venue:text(s,3),date:text(s,4),singerID:sqlite3_column_type(s,5) == SQLITE_NULL ? nil : sqlite3_column_int64(s,5),songID:sqlite3_column_type(s,6) == SQLITE_NULL ? nil : sqlite3_column_int64(s,6))) }; return result + } + func enqueue(song: Int64, singer: Int64?, venue: Int64?) throws { + guard let venue else { throw LibraryError.message("Select a current venue before adding to its queue") } + if let singer, try !members(of:venue).contains(where: { $0.id == singer }) { throw LibraryError.message("Add this singer to the current venue first") } + try execute("BEGIN IMMEDIATE") + do { + if let singer { + try assignSong(song,singer:singer) + try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_tonight WHERE venue=? AND singer=?))",[String(venue),String(singer),String(song),String(venue),String(singer)]) + } + try execute("INSERT INTO queue(venue,song,singer,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?))",[String(venue),String(song),singer.map(String.init),String(venue)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK");throw error } + } + func queue(venue: Int64?) throws -> [QueueEntry] { + guard let venue else { return [] } + let s = try statement("SELECT queue.id,queue.song,queue.singer,songs.title,COALESCE(singers.name,'No singer') FROM queue JOIN songs ON songs.id=queue.song LEFT JOIN singers ON singers.id=queue.singer WHERE queue.venue=? ORDER BY queue.position,queue.id",[String(venue)]); defer { sqlite3_finalize(s) } + var result = [QueueEntry](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),songID:sqlite3_column_int64(s,1),singerID:sqlite3_column_type(s,2) == SQLITE_NULL ? nil : sqlite3_column_int64(s,2),title:text(s,3),singer:text(s,4))) }; return result + } + func removeQueue(_ id: Int64) throws { try execute("DELETE FROM queue WHERE id=?",[String(id)]) } + func moveQueue(_ id: Int64, venue: Int64, by offset: Int) throws { + var rows = try queue(venue:venue) + guard let index = rows.firstIndex(where: { $0.id == id }), rows.indices.contains(index+offset) else { return } + rows.swapAt(index,index+offset) + try execute("BEGIN IMMEDIATE") + do { for (i,row) in rows.enumerated() { try execute("UPDATE queue SET position=? WHERE id=?",[String(i),String(row.id)]) }; try execute("COMMIT") } + catch { try? execute("ROLLBACK"); throw error } + } + func addToPlaylist(_ playlist: Int64, song: Int64) throws { + try execute("INSERT OR IGNORE INTO playlist_songs(playlist,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM playlist_songs WHERE playlist=?))",[String(playlist),String(song),String(playlist)]) + } + func playlistSongIDs(_ playlist: Int64) throws -> [Int64] { + let s = try statement("SELECT song FROM playlist_songs WHERE playlist=? ORDER BY position",[String(playlist)]); defer { sqlite3_finalize(s) } + var ids = [Int64](); while sqlite3_step(s) == SQLITE_ROW { ids.append(sqlite3_column_int64(s,0)) }; return ids + } + func removeFromPlaylist(_ playlist: Int64, song: Int64) throws { try execute("DELETE FROM playlist_songs WHERE playlist=? AND song=?",[String(playlist),String(song)]) } + func assignSong(_ song: Int64, singer: Int64) throws { + try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_songs WHERE singer=?))",[String(singer),String(song),String(singer)]) + } + func removeSingerSong(_ song: Int64, singer: Int64) throws { + try execute("DELETE FROM singer_songs WHERE singer=? AND song=?",[String(singer),String(song)]) + } + func singerSongIDs() throws -> [Int64:[Int64]] { + let s = try statement("SELECT singer,song FROM singer_songs ORDER BY singer,position,song",[]); defer { sqlite3_finalize(s) } + var result = [Int64:[Int64]]() + while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].append(sqlite3_column_int64(s,1)) } + return result + } + func defaultPreference(song: Int64) throws -> SongPreference { + let s = try statement("SELECT pitch,tempo FROM song_settings WHERE song=?",[String(song)]);defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_ROW else { return .init() } + return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:0) + } + func saveDefaultPreference(song: Int64, value: SongPreference) throws { + try execute("INSERT INTO song_settings(song,pitch,tempo) VALUES(?,?,?) ON CONFLICT(song) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo",[String(song),String(value.pitch),String(value.tempo)]) + } + func updateDuration(_ song: Int64, seconds: Double) throws { + guard seconds.isFinite && seconds >= 0 else { return } + try execute("UPDATE songs SET duration=? WHERE id=?",[String(seconds),String(song)]) + } + func setFavourite(song: Int64, singer: Int64, included: Bool) throws { + if included { try assignSong(song,singer:singer) } + try execute("UPDATE singer_songs SET favourite=? WHERE singer=? AND song=?",[included ? "1" : "0",String(singer),String(song)]) + } + func favouriteSongIDs() throws -> [Int64:Set] { + let s = try statement("SELECT singer,song FROM singer_songs WHERE favourite=1",[]);defer { sqlite3_finalize(s) } + var result = [Int64:Set]() + while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].insert(sqlite3_column_int64(s,1)) };return result + } + func stores() throws -> [MusicStore] { + let s = try statement("SELECT id,name,url,search_template FROM music_stores ORDER BY name",[]);defer { sqlite3_finalize(s) } + var result = [MusicStore]() + while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1),url:text(s,2),searchTemplate:text(s,3))) };return result + } + func saveStore(_ store: MusicStore) throws { + let name = store.name.trimmingCharacters(in:.whitespacesAndNewlines),url = store.url.trimmingCharacters(in:.whitespacesAndNewlines),search = store.searchTemplate.trimmingCharacters(in:.whitespacesAndNewlines) + guard !name.isEmpty else { throw LibraryError.message("Enter a store name") } + let cleaned = MusicStore(id:store.id,name:name,url:url,searchTemplate:search) + _ = try cleaned.destination() + if !search.isEmpty { + guard search.contains("{query}") else { throw LibraryError.message("Search URL must contain {query}") } + _ = try cleaned.destination(query:"test") + } + if store.id == 0 { try execute("INSERT INTO music_stores(name,url,search_template) VALUES(?,?,?)",[name,url,search]) } + else { try execute("UPDATE music_stores SET name=?,url=?,search_template=? WHERE id=?",[name,url,search,String(store.id)]) } + } + func deleteStore(_ id: Int64) throws { try execute("DELETE FROM music_stores WHERE id=?",[String(id)]) } + func updateMetadata(song: Int64,title: String,artist: String,album: String,label: String,rating: Int) throws { + let title = title.trimmingCharacters(in:.whitespacesAndNewlines) + guard !title.isEmpty else { throw LibraryError.message("Song title cannot be empty") } + try execute("UPDATE songs SET title=?,artist=?,album=?,label=?,rating=?,metadata_edited=1 WHERE id=?",[title,artist.trimmingCharacters(in:.whitespacesAndNewlines),album.trimmingCharacters(in:.whitespacesAndNewlines),label.trimmingCharacters(in:.whitespacesAndNewlines),String(rating),String(song)]) + guard sqlite3_changes(db) == 1 else { throw LibraryError.message("Song no longer exists") } + } + func tonightSongIDs(venue: Int64?) throws -> [Int64:[Int64]] { + guard let venue else { return [:] } + let s = try statement("SELECT singer,song FROM singer_tonight WHERE venue=? ORDER BY position,song",[String(venue)]);defer { sqlite3_finalize(s) } + var result = [Int64:[Int64]]() + while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].append(sqlite3_column_int64(s,1)) };return result + } + func addTonight(song: Int64,singer: Int64,venue: Int64) throws { + guard try members(of:venue).contains(where:{ $0.id == singer }) else { throw LibraryError.message("This singer is not a member of the current venue") } + try execute("BEGIN IMMEDIATE") + do { + try assignSong(song,singer:singer) + try execute("INSERT OR IGNORE INTO singer_tonight(venue,singer,song,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_tonight WHERE venue=? AND singer=?))",[String(venue),String(singer),String(song),String(venue),String(singer)]) + try execute("INSERT INTO queue(venue,song,singer,position) SELECT ?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?) WHERE NOT EXISTS(SELECT 1 FROM queue WHERE venue=? AND singer=? AND song=?)",[String(venue),String(song),String(singer),String(venue),String(venue),String(singer),String(song)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK");throw error } + } + func removeTonight(song: Int64,singer: Int64,venue: Int64) throws { + try execute("BEGIN IMMEDIATE") + do { + try execute("DELETE FROM singer_tonight WHERE venue=? AND singer=? AND song=?",[String(venue),String(singer),String(song)]) + try execute("DELETE FROM queue WHERE venue=? AND singer=? AND song=?",[String(venue),String(singer),String(song)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK");throw error } + } +} diff --git a/backups/before-tonight-drag-20261103-094630/KJamsCloneTests/KJamsCloneTests.swift b/backups/before-tonight-drag-20261103-094630/KJamsCloneTests/KJamsCloneTests.swift new file mode 100644 index 0000000..67ecbe3 --- /dev/null +++ b/backups/before-tonight-drag-20261103-094630/KJamsCloneTests/KJamsCloneTests.swift @@ -0,0 +1,504 @@ +import XCTest +import AVFoundation +@testable import KJamsClone + +final class KJamsCloneTests: XCTestCase { + func packet(_ instruction: UInt8, _ data: [UInt8]) -> [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] } + func testPresetTileXORAndInvalidCoordinates() { + var d = CDGDecoder() + d.apply(packet(1,[3,0])); XCTAssertEqual(d.pixels[0],3) + d.apply(packet(6,[0,5,1,1] + Array(repeating:63,count:12))) + XCTAssertEqual(d.pixels[12*300+6],5) + d.apply(packet(38,[0,5,1,1] + Array(repeating:63,count:12))) + XCTAssertEqual(d.pixels[12*300+6],0) + let old = d.pixels; d.apply(packet(6,[1,2,31,63])); XCTAssertEqual(d.pixels,old) + } + func testSeekAndTruncatedPacket() { + var d = CDGDecoder() + let data = Data(packet(1,[2,0]) + packet(1,[7,0]) + [9]) + d.render(data,at:1); XCTAssertEqual(d.pixels[0],7); XCTAssertEqual(d.packetIndex,2) + d.render(data,at:1.0/300); XCTAssertEqual(d.pixels[0],2) + d.render(data,at:0); XCTAssertEqual(d.pixels[0],0) + } + func testPaletteBorderScrollAndImage() { + var d = CDGDecoder() + d.apply(packet(30,[63,63])); XCTAssertEqual(d.palette[0],0xFFFFFFFF) + d.apply(packet(2,[4])); XCTAssertEqual(d.pixels[0],4); XCTAssertEqual(d.pixels[12*300+6],0) + d.apply(packet(20,[3,16,0])); XCTAssertEqual(d.pixels[100*300],3) + d.apply(packet(24,[0,32,0])); XCTAssertEqual(d.pixels[100*300+294],3) + XCTAssertEqual(d.image()?.width,288) + } + func testSQLitePersistenceDedupRatingsAndHistory() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let url = folder.appendingPathComponent("test.sqlite") + do { + let db = try Library(url:url) + try db.add(title:"Singer's Song",audio:"/test.mp3",cdg:nil) + try db.add(title:"Duplicate",audio:"/test.mp3",cdg:"/test.cdg") + let song = try XCTUnwrap(db.songs().first) + try db.rate(song.id,5); XCTAssertThrowsError(try db.rate(song.id,6)) + try db.addEntity("singers",name:"O'Neil"); try db.addEntity("venues",name:"Hall") + try db.record(song.id,singer:db.entities("singers").first?.id,venue:db.entities("venues").first?.id) + } + let reopened = try Library(url:url); let songs = try reopened.songs() + XCTAssertEqual(songs.count,1); XCTAssertEqual(songs[0].rating,5); XCTAssertEqual(songs[0].plays,1); XCTAssertEqual(songs[0].cdg,"/test.cdg") + XCTAssertEqual(try reopened.entities("singers").first?.name,"O'Neil") + } + func testPairedImportAndContentDedup() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let audio = root.appendingPathComponent("Test.mp3"), cdg = root.appendingPathComponent("Test.CDG") + try Data([1,2,3]).write(to:audio); try Data(packet(1,[2,0])).write(to:cdg) + let db = try Library(url:root.appendingPathComponent("test.sqlite")) + let importer = Importer(media:root.appendingPathComponent("Media")) + try importer.run([audio],library:db); try importer.run([audio],library:db) + XCTAssertEqual(try db.songs().count,1) + XCTAssertNotNil(try db.songs().first?.cdg) + } + + func testZippedMP3GImportAndUnsafeArchiveRejection() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let db = try Library(url:folder.appendingPathComponent("db.sqlite")) + let importer = Importer(media:folder.appendingPathComponent("Media")) + try importer.run([fixtures.appendingPathComponent("Demo.zip")],library:db) + let song = try XCTUnwrap(db.songs().first) + XCTAssertEqual(song.title,"Demo"); XCTAssertNotNil(song.cdg) + XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio)) + XCTAssertThrowsError(try importer.run([fixtures.appendingPathComponent("Unsafe.zip")],library:db)) + } + @MainActor func testMP3PlaybackPauseAndSeek() throws { + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let player = Player() + defer { player.stop() } + try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0)) + XCTAssertGreaterThan(player.duration,3.9); XCTAssertTrue(player.playing) + player.toggle(); XCTAssertFalse(player.playing) + player.seek(2); XCTAssertEqual(player.time,2,accuracy:0.05); XCTAssertNotNil(player.image) + player.seek(0.1); XCTAssertEqual(player.time,0.1,accuracy:0.05) + } + @MainActor func testCreateSingerAndVenueThroughAppModel() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let model = AppModel(storageURL: root) + XCTAssertNil(model.error) + let singer = try model.createEntity(.singer, name: " Alex ") + let venue = try model.createEntity(.venue, name: "Town Hall") + XCTAssertEqual(model.singers.first?.name, "Alex") + XCTAssertEqual(model.venues.first?.name, "Town Hall") + XCTAssertEqual(try model.createEntity(.singer, name: "Alex"), singer) + XCTAssertEqual(model.singers.count, 1) + XCTAssertThrowsError(try model.createEntity(.venue, name: " ")) + let reopened = AppModel(storageURL: root) + XCTAssertEqual(reopened.singers.first?.id, singer) + XCTAssertEqual(reopened.venues.first?.id, venue) + } + + @MainActor func testVenuesShareSingersAndRememberSelection() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root) + let hall = try model.createEntity(.venue,name:"Hall") + let alex = try model.createEntity(.singer,name:"Alex") + let club = try model.createEntity(.venue,name:"Club") + XCTAssertTrue(model.venueSingers.isEmpty) + model.membership(singer:alex,included:true) + XCTAssertEqual(model.venueSingers.map(\.id),[alex]) + model.chooseVenue(hall); XCTAssertEqual(model.venueSingers.map(\.id),[alex]) + model.chooseVenue(club); model.membership(singer:alex,included:false) + XCTAssertTrue(model.venueSingers.isEmpty) + let reopened = AppModel(storageURL:root) + XCTAssertEqual(reopened.currentVenue,club); XCTAssertTrue(reopened.venueSingers.isEmpty) + reopened.chooseVenue(hall); XCTAssertEqual(reopened.venueSingers.map(\.id),[alex]) + XCTAssertEqual(reopened.singers.count,1) + } + func testMigrationAndMembershipRemovalDoesNotReappear() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let url = root.appendingPathComponent("db.sqlite") + var singer: Int64 = 0, venue: Int64 = 0 + do { + let db = try Library(url:url) + try db.add(title:"Song",audio:"/a.mp3",cdg:nil) + try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall") + singer = try XCTUnwrap(db.entities("singers").first?.id); venue = try XCTUnwrap(db.entities("venues").first?.id) + try db.record(XCTUnwrap(db.songs().first?.id),singer:singer,venue:venue) + try db.execute("PRAGMA user_version=1") + } + do { + let migrated = try Library(url:url) + XCTAssertEqual(try migrated.members(of:venue).map(\.id),[singer]) + try migrated.setMembership(singer:singer,venue:venue,included:false) + } + let reopened = try Library(url:url) + XCTAssertTrue(try reopened.members(of:venue).isEmpty) + XCTAssertEqual(try reopened.history(venue:venue).count,1) + } + func testQueuePlaylistsPreferencesAndDeletion() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let db = try Library(url:root.appendingPathComponent("db.sqlite")) + try db.add(title:"First",audio:"/1.mp3",cdg:nil) + try db.add(title:"Second",audio:"/2.mp3",cdg:nil) + let songs = try db.songs() + try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall"); try db.addEntity("venues",name:"Club") + let singer = try XCTUnwrap(db.entities("singers").first?.id) + let hall = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Hall" })?.id) + let club = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Club" })?.id) + XCTAssertThrowsError(try db.enqueue(song:songs[0].id,singer:singer,venue:hall)) + try db.setMembership(singer:singer,venue:hall,included:true) + try db.enqueue(song:songs[0].id,singer:singer,venue:hall); try db.enqueue(song:songs[1].id,singer:singer,venue:hall) + XCTAssertTrue(try db.queue(venue:club).isEmpty) + let second = try XCTUnwrap(db.queue(venue:hall).last) + try db.moveQueue(second.id,venue:hall,by:-1) + XCTAssertEqual(try db.queue(venue:hall).first?.songID,songs[1].id) + let preference = SongPreference(pitch:3,tempo:0.85,rating:4) + try db.savePreference(song:songs[0].id,singer:singer,value:preference) + XCTAssertEqual(try db.preference(song:songs[0].id,singer:singer),preference) + XCTAssertThrowsError(try db.savePreference(song:songs[0].id,singer:singer,value:.init(pitch:99))) + try db.addEntity("playlists",name:"Party") + let playlist = try XCTUnwrap(db.entities("playlists").first?.id) + try db.addToPlaylist(playlist,song:songs[0].id); try db.addToPlaylist(playlist,song:songs[0].id) + XCTAssertEqual(try db.playlistSongIDs(playlist),[songs[0].id]) + try db.record(songs[0].id,singer:singer,venue:hall) + try db.rename("singers",id:singer,name:"Alex Jones") + XCTAssertEqual(try db.history(venue:hall).first?.singer,"Alex Jones") + try db.deleteEntity("singers",id:singer) + XCTAssertTrue(try db.queue(venue:hall).isEmpty) + XCTAssertEqual(try db.history(venue:hall).count,1) + try db.selectVenue(hall); try db.deleteEntity("venues",id:hall) + XCTAssertNil(try db.currentVenue()); XCTAssertEqual(try db.history(venue:nil).count,1) + } + @MainActor func testTempoSourceClockPauseAndResume() async throws { + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let player = Player(); defer { player.stop() } + let song = Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0) + try player.load(song,preference:.init(pitch:2,tempo:1.5,rating:3)) + XCTAssertEqual(player.pitch,2); XCTAssertEqual(player.tempo,1.5) + try await Task.sleep(nanoseconds:500_000_000) + XCTAssertGreaterThan(player.time,0.45); XCTAssertLessThan(player.time,1.3) + player.toggle(); let paused = player.time + try await Task.sleep(nanoseconds:100_000_000) + XCTAssertEqual(player.time,paused,accuracy:0.025) + player.seek(1.5); player.toggle() + try await Task.sleep(nanoseconds:200_000_000) + XCTAssertGreaterThan(player.time,1.6); XCTAssertNotNil(player.image) + } + func testNewTestMediaMetadataAndCDGPairImport() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let db = try Library(url:root.appendingPathComponent("db.sqlite")) + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let importer = Importer(media:root.appendingPathComponent("Media")) + try importer.run([fixtures.appendingPathComponent("TestTrack.cdg")],library:db) + let song = try XCTUnwrap(db.songs().first) + XCTAssertEqual(song.title,"MP3 Plus G Test") + XCTAssertEqual(song.artist,"KJamsClone Test Generator") + XCTAssertNotNil(song.cdg) + let bytes = try Data(contentsOf:fixtures.appendingPathComponent("TestTrack.cdg")) + XCTAssertEqual(bytes.count,20*300*24) + var decoder = CDGDecoder(); decoder.render(bytes,at:2) + XCTAssertTrue(decoder.pixels.contains(1)); XCTAssertTrue(decoder.pixels.contains(2)) + } + @MainActor func testAutomaticVenueQueueAdvancesAndRecordsSinger() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root); defer { model.player.stop() } + _ = try model.createEntity(.venue,name:"Test Hall") + _ = try model.createEntity(.singer,name:"Alex") + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + model.importFiles([fixtures.appendingPathComponent("Demo.zip")]) + let song = try XCTUnwrap(model.songs.first) + model.enqueue(song.id); model.enqueue(song.id); model.playQueueNext() + XCTAssertEqual(model.queue.count,1); XCTAssertEqual(model.history.count,1) + model.player.seek(model.player.duration-0.1) + for _ in 0..<25 { + if model.queue.isEmpty { break } + try await Task.sleep(nanoseconds:100_000_000) + } + XCTAssertNil(model.error); XCTAssertTrue(model.queue.isEmpty) + XCTAssertEqual(model.history.count,2); XCTAssertEqual(model.history.first?.singer,"Alex") + } + func testWaveformRepresentsSilenceAndSignalInTimeOrder() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let url = folder.appendingPathComponent("signal.wav") + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:8000,channels:1)) + do { + let file = try AVAudioFile(forWriting:url,settings:format.settings) + let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:4096)); buffer.frameLength = 4096 + let samples = try XCTUnwrap(buffer.floatChannelData?[0]) + for i in 0..<4096 { samples[i] = i < 2048 ? 0 : Float(sin(Double(i)*0.2)*0.5) } + try file.write(from:buffer) + } + let envelope = try AudioWaveform.read(url:url,bins:32) + XCTAssertEqual(envelope.count,32) + XCTAssertTrue(envelope.prefix(16).allSatisfy { $0 == 0 }) + XCTAssertTrue(envelope.suffix(16).allSatisfy { $0 > 0.8 && $0 <= 1 }) + XCTAssertThrowsError(try AudioWaveform.read(url:url,cancelled:{true})) + } + @MainActor func testWaveformAnalysisAndStopClearsTrackData() async throws { + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let player = Player(); defer { player.stop() } + try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:nil,rating:0,plays:0)) + player.toggle() + for _ in 0..<50 { + if !player.waveformLoading { break } + try await Task.sleep(nanoseconds:20_000_000) + } + XCTAssertEqual(player.waveform.count,4096) + XCTAssertFalse(player.waveformUnavailable) + player.stop() + XCTAssertTrue(player.waveform.isEmpty) + XCTAssertFalse(player.waveformLoading) + try await Task.sleep(nanoseconds:50_000_000) + XCTAssertTrue(player.waveform.isEmpty) + } + + @MainActor func testConnectedSingerCreationAndSharedRepertoire() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root) + let hall = try model.createEntity(.venue,name:"Hall") + try model.library?.add(title:"Song",audio:"/sample.mp3",cdg:nil);try model.refresh() + let song = try XCTUnwrap(model.songs.first?.id) + let alex = try model.createSinger(name:" Alex ",venue:hall,songs:[song]) + XCTAssertEqual(model.selectedSinger,alex);XCTAssertEqual(model.currentVenue,hall) + XCTAssertEqual(model.venueSingers.map(\.id),[alex]);XCTAssertEqual(model.songs(for:alex).map(\.id),[song]) + XCTAssertThrowsError(try model.createSinger(name:"Broken",venue:hall,songs:[999999])) + XCTAssertFalse(try XCTUnwrap(model.library).entities("singers").contains { $0.name == "Broken" }) + let club = try model.createEntity(.venue,name:"Club");model.membership(singer:alex,included:true) + XCTAssertEqual(model.songs(for:alex).map(\.id),[song]) + model.assign(song,to:alex);XCTAssertEqual(model.songs(for:alex).count,1) + let reopened = AppModel(storageURL:root) + XCTAssertEqual(reopened.currentVenue,club);XCTAssertEqual(reopened.songs(for:alex).map(\.id),[song]) + reopened.assign(song,to:alex,included:false) + XCTAssertTrue(AppModel(storageURL:root).songs(for:alex).isEmpty) + } + func testQueueRemembersSingerSongWithoutDuplicatingAssignment() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let db = try Library(url:root.appendingPathComponent("db.sqlite")) + try db.addEntity("singers",name:"Alex");try db.addEntity("venues",name:"Hall") + try db.add(title:"Song",audio:"/a.mp3",cdg:nil) + let singer = try XCTUnwrap(db.entities("singers").first?.id),venue = try XCTUnwrap(db.entities("venues").first?.id),song = try XCTUnwrap(db.songs().first?.id) + try db.setMembership(singer:singer,venue:venue,included:true) + try db.enqueue(song:song,singer:singer,venue:venue);try db.enqueue(song:song,singer:singer,venue:venue) + XCTAssertEqual(try db.singerSongIDs()[singer],[song]);XCTAssertEqual(try db.queue(venue:venue).count,2) + try db.removeSingerSong(song,singer:singer) + XCTAssertNil(try db.singerSongIDs()[singer]);XCTAssertEqual(try db.songs().count,1) + } + func testFrequencyBandsDistinguishBassAndTreble() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let url = root.appendingPathComponent("bands.wav") + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:16000,channels:1)) + do { + let file = try AVAudioFile(forWriting:url,settings:format.settings) + let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:8192));buffer.frameLength = 8192 + for i in 0..<8192 { buffer.floatChannelData![0][i] = Float(sin(Double(i)*2*Double.pi*(i < 4096 ? 80 : 6000)/16000)*0.2) } + try file.write(from:buffer) + } + let analysis = try AudioWaveform.analyse(url:url,bins:32) + XCTAssertGreaterThan(analysis.bands[8].low,analysis.bands[8].high*3) + XCTAssertGreaterThan(analysis.bands[24].high,analysis.bands[24].low*3) + } + private func toneRMS(frequency: Double, low: Double = 0, filter: Double = 0, enabled: Bool = true) throws -> Double { + let engine = AVAudioEngine(),node = AVAudioPlayerNode(),eq = AVAudioUnitEQ(numberOfBands:4) + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:1)) + engine.attach(node);engine.attach(eq);engine.connect(node,to:eq,format:format);engine.connect(eq,to:engine.mainMixerNode,format:format) + AudioTone.configure(eq,sampleRate:44100,low:low,mid:0,high:0,filter:filter,enabled:enabled) + try engine.enableManualRenderingMode(.offline,format:format,maximumFrameCount:1024) + let input = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:32768));input.frameLength = 32768 + for i in 0..<32768 { input.floatChannelData![0][i] = Float(sin(Double(i)*2*Double.pi*frequency/44100)*0.1) } + node.scheduleBuffer(input);try engine.start();node.play();defer { node.stop();engine.stop() } + let output = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:1024)) + var energy: Double = 0,count = 0 + for block in 0..<12 { + let status = try engine.renderOffline(1024,to:output) + XCTAssertEqual(status,.success) + if block >= 2 { for i in 0..]() + @Published var musicStores = [MusicStore]() + @Published var readingDurations = false + var newSingerSongs = Set() + private var songIndex = [Int64:Song]() + @Published var venues = [PersonPlace]() + @Published var venueSingers = [PersonPlace]() + @Published var playlists = [PersonPlace]() + @Published var queue = [QueueEntry]() + @Published var history = [Performance]() + @Published private(set) var currentVenue: Int64? + @Published var selectedSinger: Int64? + @Published var autoAdvance = true + @Published var error: String? + @Published var creating: EntityKind? + let player: Player + private(set) var library: Library? + let root: URL + var currentVenueName: String { venues.first { $0.id == currentVenue }?.name ?? "No venue selected" } + init(storageURL: URL? = nil) { + root = storageURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("KJamsClone") + player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms")) + perform { + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + library = try Library(url:root.appendingPathComponent("Library.sqlite")); try refresh() + } + player.onFinished = { [weak self] in + guard let self, self.autoAdvance, !self.queue.isEmpty else { return } + self.playQueueNext() + } + } + func perform(_ work: () throws -> Void) { do { try work() } catch { self.error = error.localizedDescription } } + func refresh() throws { + guard let library else { throw LibraryError.message("Library unavailable") } + songs = try library.songs();songIndex = Dictionary(uniqueKeysWithValues:songs.map { ($0.id,$0) }); singers = try library.entities("singers"); venues = try library.entities("venues") + singerSongIDs = try library.singerSongIDs();favourites = try library.favouriteSongIDs();musicStores = try library.stores() + playlists = try library.entities("playlists"); currentVenue = try library.currentVenue() + venueSingers = try currentVenue.map { try library.members(of:$0) } ?? singers + if let selectedSinger, !venueSingers.contains(where: { $0.id == selectedSinger }) { self.selectedSinger = nil } + queue = try library.queue(venue:currentVenue); history = try library.history(venue:currentVenue) + } + func chooseVenue(_ id: Int64?) { + perform { + guard let library else { throw LibraryError.message("Library unavailable") } + try library.selectVenue(id); selectedSinger = nil; try refresh() + } + } + func membership(singer: Int64, included: Bool) { + perform { + guard let library, let currentVenue else { throw LibraryError.message("Select a current venue first") } + try library.setMembership(singer:singer,venue:currentVenue,included:included); try refresh() + } + } + @discardableResult func createEntity(_ kind: EntityKind, name: String) throws -> Int64 { + guard let library else { throw LibraryError.message("Library unavailable") } + let trimmed = name.trimmingCharacters(in:.whitespacesAndNewlines) + try library.addEntity(kind.table,name:trimmed) + guard let entity = try library.entities(kind.table).first(where: { $0.name == trimmed }) else { throw LibraryError.message("Could not load new entry") } + if kind == .singer { + if let currentVenue { try library.setMembership(singer:entity.id,venue:currentVenue,included:true) } + selectedSinger = entity.id + } else { try library.selectVenue(entity.id); selectedSinger = nil } + try refresh(); return entity.id + } + func beginSinger(song: Int64? = nil) { newSingerSongs = Set(song.map { [$0] } ?? []); creating = .singer } + func songs(for singer: Int64) -> [Song] { + (singerSongIDs[singer] ?? []).compactMap { songIndex[$0] } + } + func assign(_ song: Int64, to singer: Int64, included: Bool = true) { + perform { + guard let library else { throw LibraryError.message("Library unavailable") } + if included { try library.assignSong(song,singer:singer) } else { try library.removeSingerSong(song,singer:singer) } + try refresh() + } + } + func createSinger(name: String, venue: Int64, songs: Set) throws -> Int64 { + guard let library else { throw LibraryError.message("Library unavailable") } + guard venues.contains(where: { $0.id == venue }) else { throw LibraryError.message("Choose a venue first") } + let name = name.trimmingCharacters(in:.whitespacesAndNewlines) + try library.execute("BEGIN IMMEDIATE") + let id: Int64 + do { + try library.addEntity("singers",name:name) + guard let singer = try library.entities("singers").first(where: { $0.name == name }) else { throw LibraryError.message("Could not load singer") } + id = singer.id + try library.setMembership(singer:id,venue:venue,included:true) + for song in songs { try library.assignSong(song,singer:id) } + try library.selectVenue(venue);try library.execute("COMMIT") + } catch { try? library.execute("ROLLBACK");throw error } + try refresh();selectedSinger = id;return id + } + func rename(_ table: String, id: Int64, name: String) { perform { try library?.rename(table,id:id,name:name); try refresh() } } + func delete(_ table: String, id: Int64) { perform { try library?.deleteEntity(table,id:id); try refresh() } } + func preference(_ song: Int64) -> SongPreference { + guard let library else { return .init() } + if let selectedSinger { return (try? library.preference(song:song,singer:selectedSinger)) ?? .init() } + return (try? library.defaultPreference(song:song)) ?? .init() + } + func savePreference(song: Int64, value: SongPreference) { + perform { + guard let library, let selectedSinger else { throw LibraryError.message("Select a singer to save their song preferences") } + try library.savePreference(song:song,singer:selectedSinger,value:value); try refresh() + } + } + func play(_ song: Song, singer: Int64? = nil) throws { + guard let library else { throw LibraryError.message("Library unavailable") } + let singer = singer ?? selectedSinger + if let singer, let currentVenue, try !library.members(of:currentVenue).contains(where: { $0.id == singer }) { throw LibraryError.message("This singer is not in the current venue") } + let preference = try singer.map { try library.preference(song:song.id,singer:$0) } ?? library.defaultPreference(song:song.id) + try player.load(song,preference:preference) + do { try library.updateDuration(song.id,seconds:player.duration);try library.record(song.id,singer:singer,venue:currentVenue); selectedSinger = singer; try refresh() } + catch { player.stop(); throw error } + } + func enqueue(_ song: Int64) { perform { try library?.enqueue(song:song,singer:selectedSinger,venue:currentVenue); try refresh() } } + func playQueueNext() { + perform { + guard let row = queue.first, let song = songs.first(where: { $0.id == row.songID }) else { return } + // Explicitly clear selection for unassigned queue entries. + selectedSinger = row.singerID + try play(song,singer:row.singerID); try library?.removeQueue(row.id); try refresh() + } + } + func moveQueue(_ id: Int64, by offset: Int) { perform { if let currentVenue { try library?.moveQueue(id,venue:currentVenue,by:offset); try refresh() } } } + func removeQueue(_ id: Int64) { perform { try library?.removeQueue(id); try refresh() } } + func createPlaylist(_ name: String) { perform { try library?.addEntity("playlists",name:name); try refresh() } } + func addToPlaylist(_ playlist: Int64, song: Int64) { perform { try library?.addToPlaylist(playlist,song:song); try refresh() } } + func setFavourite(_ song: Int64, singer: Int64, included: Bool) { perform { try library?.setFavourite(song:song,singer:singer,included:included);try refresh() } } + func updateTuning(_ song: Int64, pitch: Double? = nil, tempo: Double? = nil) { + perform { + guard let library else { throw LibraryError.message("Library unavailable") } + var value = preference(song) + if let pitch { value.pitch = pitch };if let tempo { value.tempo = tempo } + if let selectedSinger { try library.savePreference(song:song,singer:selectedSinger,value:value) } + else { try library.saveDefaultPreference(song:song,value:value) } + if player.currentSongID == song { if pitch != nil { player.pitch = value.pitch };if tempo != nil { player.tempo = value.tempo } } + try refresh() + } + } + func saveStore(_ store: MusicStore) throws { guard let library else { throw LibraryError.message("Library unavailable") };try library.saveStore(store);try refresh() } + func deleteStore(_ id: Int64) { perform { try library?.deleteStore(id);try refresh() } } + func scanDurations() { + guard !readingDurations else { return };readingDurations = true + let songs = self.songs.filter { $0.duration == 0 } + Task.detached(priority:.utility) { [weak self] in + var durations = [(Int64,Double)]() + for song in songs { + if let file = try? AVAudioFile(forReading:URL(fileURLWithPath:song.audio)) { durations.append((song.id,Double(file.length)/file.processingFormat.sampleRate)) } + } + let completedDurations = durations + await MainActor.run { [weak self] in + guard let self else { return } + self.perform { for (id,value) in completedDurations { try self.library?.updateDuration(id,seconds:value) };try self.refresh() } + self.readingDurations = false + } + } + } + func updateMetadata(_ song: Song,title: String,artist: String,album: String,label: String,rating: Int) throws { + guard let library else { throw LibraryError.message("Library unavailable") } + try library.updateMetadata(song:song.id,title:title,artist:artist,album:album,label:label,rating:rating) + if player.currentSongID == song.id { player.title = title.trimmingCharacters(in:.whitespacesAndNewlines) } + try refresh() + } + func importFiles(_ urls: [URL]) { perform { guard let library else { throw LibraryError.message("Library unavailable") }; try Importer(media:root.appendingPathComponent("Media")).run(urls,library:library); try refresh() } } +} +@main struct KJamsCloneApp: App { + @StateObject private var model = AppModel() + var body: some Scene { + Window("KJamsClone", id: "main") { + LibraryView(model: model).frame(minWidth: 1060, minHeight: 680).preferredColorScheme(.dark) + }.defaultSize(width: 1260, height: 840) + .commands { + CommandGroup(replacing: .newItem) { + Button("New Singer…") { model.beginSinger() }.keyboardShortcut("n", modifiers: [.command, .shift]) + Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option]) + } + } + Window("Video", id: "lyrics") { + LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark) + }.defaultSize(width: 600, height: 400) + } +} +private enum Classic { + static let frame = Color(red: 0.15, green: 0.15, blue: 0.15) + static let navy = Color(red: 0.008, green: 0.065, blue: 0.16) + static let blue = Color(red: 0.015, green: 0.17, blue: 0.42) + static let header = Color(white: 0.115) + static let line = Color(white: 0.42) +} +struct LyricsView: View { + @ObservedObject var player: Player + var body: some View { + ZStack { + Color.black + if let image = player.image { + Image(decorative: image, scale: 1).interpolation(.none).resizable().aspectRatio(1.5, contentMode: .fit) + } + } + } +} +/// This is another view of the same published CGImage used by the Video window. +/// It owns no decoder, audio player or clock, so seeking/pause/tempo stay identical. +private struct SidebarVideoMonitor: View { + let player: Player + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Label("Video Monitor", systemImage: "tv") + .font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7)) + LyricsView(player: player) + .aspectRatio(1.5, contentMode: .fit) + .clipped() + .overlay(Rectangle().strokeBorder(.white.opacity(0.25), lineWidth: 1)) + .accessibilityLabel("Video monitor, synchronized with the Video window") + }.frame(maxWidth: .infinity) + } +} +private struct SilverSquareStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label.foregroundStyle(Color(white: 0.25)).padding(.horizontal, 6).padding(.vertical, 3) + .background(LinearGradient(colors: [Color(white: 0.95), Color(white: 0.64)], startPoint: .top, endPoint: .bottom), in: RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Color(white: 0.45), lineWidth: 1.5)) + .overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(.white.opacity(0.7)).padding(3)) + .opacity(configuration.isPressed ? 0.7 : 1) + } +} +private struct SilverButton: View { + let symbol: String + let label: String + var size: CGFloat = 40 + var active = false + var enabled = true + let action: () -> Void + var body: some View { + Button(action: action) { + Image(systemName: symbol).font(.system(size: size * 0.41, weight: .semibold)) + .foregroundStyle(active ? Color.blue : Color(white: 0.24)) + .frame(width: size, height: size) + .background(LinearGradient(colors: [.white, Color(white: 0.85), Color(white: 0.63)], startPoint: .top, endPoint: .bottom), in: Circle()) + .overlay(Circle().strokeBorder(Color(white: 0.45), lineWidth: 1.5)) + .overlay(Circle().strokeBorder(.white.opacity(0.65), lineWidth: 1).padding(3)) + .shadow(color: .black.opacity(0.7), radius: 1, y: 1) + }.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.72) + .help(label).accessibilityLabel(label) + } +} +struct LibraryView: View { + @ObservedObject var model: AppModel + @State private var selected: Int64? + @State private var search = "" + @State private var name = "" + @State private var importing = false + @State private var source = "Library" + @State private var singersExpanded = true + @State private var expandedSingers = Set() + @State private var expandedSongLists = Set() + @State private var songPickerSinger: PersonPlace? + @State private var singerSection = "All Songs" + @State private var storesExpanded = true + @State private var focusedStore: Int64? + @State private var editingSong: Song? + @State private var librarySort = "Song Name" + @State private var sortAscending = true + @Environment(\.openWindow) private var openWindow + private var visibleSongs: [Song] { + let filtered = model.songs.filter { search.isEmpty || ($0.title+" "+$0.artist+" "+$0.album).localizedCaseInsensitiveContains(search) } + return filtered.sorted { a,b in + if a.id == b.id { return false } + if librarySort == "Duration" { return a.duration == b.duration ? a.id < b.id : sortAscending ? a.duration < b.duration : a.duration > b.duration } + if librarySort == "Rating" { return a.rating == b.rating ? a.id < b.id : sortAscending ? a.rating < b.rating : a.rating > b.rating } + let first = librarySort == "Artist" ? a.artist : librarySort == "Album" ? a.album : a.title + let second = librarySort == "Artist" ? b.artist : librarySort == "Album" ? b.album : b.title + let order = first.localizedStandardCompare(second) + return order == .orderedSame ? a.id < b.id : sortAscending ? order == .orderedAscending : order == .orderedDescending + } + } + + private var selectedSong: Song? { model.songs.first { $0.id == selected } } + var body: some View { + VStack(spacing: 0) { + TransportView(player: model.player, search: $search, hasSelection: selectedSong != nil, + play: playSelected, next: playNext, display: { openWindow(id: "lyrics") }) + .padding(.horizontal, 18).padding(.top, 18).padding(.bottom, 22) + HSplitView { + sidebar.frame(minWidth: 190, idealWidth: 220, maxWidth: 240) + VStack(spacing: 12) { + if source == "Library" { songTable } + else if source == "Queue" { queuePane } + else if source == "History" { historyPane } + else if source == "Playlists" { playlistPane } + else if source == "Singer Songs" { singerSongPane } + else if source == "Music Stores" { MusicStoresPane(model:model,selected:$focusedStore) } + else { directory } + detailPanel.frame(height: 135) + }.padding(.leading, 12).frame(minWidth: 780) + }.padding(.horizontal, 18) + footer.padding(.horizontal, 18).padding(.vertical, 12) + } + .background(Classic.frame) + .sheet(item: $model.creating) { kind in + AddEntitySheet(model: model, kind: kind) { id in + if kind == .singer { model.selectedSinger = id;expandedSingers.insert(id);expandedSongLists.insert(id);singersExpanded = true;singerSection = "All Songs";source = "Singer Songs" } else { model.chooseVenue(id) } + } + } + .sheet(item:$editingSong) { song in SongMetadataSheet(model:model,song:song) } + .sheet(item:$songPickerSinger) { singer in SingerSongsSheet(model:model,singer:singer) } + .fileImporter(isPresented: $importing, allowedContentTypes: [.audio, .zip, UTType(filenameExtension: "cdg") ?? .data], allowsMultipleSelection: true) { result in + switch result { + case .success(let urls): model.importFiles(urls) + case .failure(let error): model.error = error.localizedDescription + } + } + .alert("KJamsClone", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { + Button("OK") { model.error = nil } + } message: { Text(model.error ?? "") } + } + private func sourceCount(_ source: String) -> Int { + switch source { + case "Library": return model.songs.count + case "Singers": return model.venueSingers.count + case "Venues": return model.venues.count + case "Queue": return model.queue.count + case "History": return model.history.count + default: return model.playlists.count + } + } + private var sidebar: some View { + VStack(alignment:.leading,spacing:0) { + Text("Source").font(.system(size:15,weight:.semibold)).padding(.horizontal,10).frame(height:31) + .frame(maxWidth:.infinity,alignment:.leading).background(Classic.header) + ScrollView { + VStack(spacing:0) { + sourceRow("Library",icon:"music.note.house.fill",color:.yellow) + HStack(spacing:5) { + Button { singersExpanded.toggle() } label: { Image(systemName:singersExpanded ? "chevron.down" : "chevron.right").font(.system(size:10)).frame(width:12) }.buttonStyle(.plain).accessibilityLabel("Expand singers") + Button { source = "Singers" } label: { HStack { Image(systemName:"person.2.fill").foregroundStyle(.green);Text("Singers");Spacer();Text("\(model.venueSingers.count)").font(.caption).foregroundStyle(.secondary) } }.buttonStyle(.plain) + Button { model.beginSinger() } label: { Image(systemName:"plus.circle.fill").foregroundStyle(.green) }.buttonStyle(.plain).help("New singer and songs") + }.padding(.horizontal,10).frame(height:34).background(source == "Singers" ? Color.blue.opacity(0.65) : .clear) + if singersExpanded { + Text(model.currentVenueName).font(.system(size:10)).foregroundStyle(.secondary).lineLimit(1).padding(.leading,30).frame(maxWidth:.infinity,alignment:.leading).padding(.vertical,4) + ForEach(model.venueSingers) { singer in sidebarSinger(singer) } + if model.venueSingers.isEmpty { Text("Add a singer with +").font(.caption).foregroundStyle(.secondary).padding(8) } + } + HStack { + Button { storesExpanded.toggle() } label: { Image(systemName:storesExpanded ? "chevron.down" : "chevron.right").font(.system(size:10)) }.buttonStyle(.plain) + Button { source = "Music Stores" } label: { Label("Music Stores",systemImage:"bag.fill").foregroundStyle(.green);Spacer() }.buttonStyle(.plain) + }.padding(.horizontal,12).frame(height:34) + if storesExpanded { + ForEach(model.musicStores) { store in + Button { focusedStore = store.id;source = "Music Stores" } label: { Label(store.name,systemImage:"globe").font(.system(size:12)).lineLimit(1).padding(.leading,32).frame(maxWidth:.infinity,alignment:.leading).frame(height:26) }.buttonStyle(.plain) + } + Button("Configure Stores…") { source = "Music Stores" }.buttonStyle(.plain).font(.system(size:11)).foregroundStyle(.secondary).padding(.leading,32).frame(maxWidth:.infinity,alignment:.leading).frame(height:25) + } + sourceRow("Venues",icon:"building.2.fill",color:.cyan) + sourceRow("Queue",icon:"list.number",color:.orange) + sourceRow("History",icon:"clock.arrow.circlepath",color:.purple) + sourceRow("Playlists",icon:"music.note.list",color:.mint) + } + } + SidebarVideoMonitor(player:model.player).padding(10) + }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line,lineWidth:1)) + } + private func sourceRow(_ title: String, icon: String, color: Color) -> some View { + Button { source = title } label: { + HStack(spacing:10) { + Image(systemName:icon).foregroundStyle(color).frame(width:22) + Text(title).font(.system(size:16,weight:.medium));Spacer() + Text("\(sourceCount(title))").font(.system(size:11)).foregroundStyle(.white.opacity(0.6)) + }.padding(.horizontal,12).frame(height:34).contentShape(Rectangle()) + .background(source == title ? Color(red:0,green:0.36,blue:1) : .clear) + }.buttonStyle(.plain) + } + private func sidebarSinger(_ singer: PersonPlace) -> some View { + VStack(alignment:.leading,spacing:0) { + HStack(spacing:5) { + Button { + if expandedSingers.contains(singer.id) { expandedSingers.remove(singer.id) } else { expandedSingers.insert(singer.id) } + } label: { Image(systemName:expandedSingers.contains(singer.id) ? "chevron.down" : "chevron.right").font(.system(size:9)).frame(width:12) }.buttonStyle(.plain).accessibilityLabel("Expand \(singer.name)’s songs") + Button { model.selectedSinger = singer.id;singerSection = "All Songs";source = "Singer Songs" } label: { + HStack(spacing:5) { Text(singer.name).lineLimit(1);Spacer();Text("\(model.singerSongIDs[singer.id]?.count ?? 0)").font(.system(size:10)).foregroundStyle(.secondary) } + }.buttonStyle(.plain) + Button { model.selectedSinger = singer.id;songPickerSinger = singer;expandedSingers.insert(singer.id) } label: { Image(systemName:"plus").font(.system(size:10,weight:.bold)) }.buttonStyle(.plain).help("Add songs to \(singer.name)") + }.font(.system(size:13,weight:.medium)).padding(.leading,25).padding(.trailing,12).frame(height:29) + .background(model.selectedSinger == singer.id && source == "Singer Songs" ? Color.blue.opacity(0.25) : .clear) + if expandedSingers.contains(singer.id) { + singerFolder("Tonight",icon:"music.note.list",singer:singer) + singerFolder("Favourites",icon:"star.fill",singer:singer) + singerFolder("History",icon:"clock.arrow.circlepath",singer:singer) + HStack(spacing:3) { + Button { if expandedSongLists.contains(singer.id) { expandedSongLists.remove(singer.id) } else { expandedSongLists.insert(singer.id) } } label: { Image(systemName:expandedSongLists.contains(singer.id) ? "chevron.down" : "chevron.right").font(.system(size:9)) }.buttonStyle(.plain) + singerFolder("All Songs",icon:"folder",singer:singer) + }.padding(.leading,28) + if expandedSongLists.contains(singer.id) { + ForEach(model.songs(for:singer.id)) { song in + Button { model.selectedSinger = singer.id;selected = song.id;singerSection = "All Songs";source = "Singer Songs" } label: { + Label(song.title,systemImage:"music.note").font(.system(size:11)).lineLimit(1).frame(maxWidth:.infinity,alignment:.leading).padding(.leading,44).padding(.trailing,8).frame(height:25) + }.buttonStyle(.plain) + .contextMenu { + Button("Play for \(singer.name)") { model.selectedSinger = singer.id;model.perform { try model.play(song,singer:singer.id) } } + Button("Queue for \(singer.name)") { model.selectedSinger = singer.id;model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Button(model.favourites[singer.id]?.contains(song.id) == true ? "Remove Favourite" : "Add to Favourites") { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } + Button("Remove from Singer",role:.destructive) { model.assign(song.id,to:singer.id,included:false) } + } + } + if model.songs(for:singer.id).isEmpty { Text("No songs · use +").font(.system(size:10)).foregroundStyle(.secondary).padding(.leading,44).padding(.vertical,5) } + } + } + } + } + private func singerFolder(_ title: String, icon: String, singer: PersonPlace) -> some View { + Button { model.selectedSinger = singer.id;singerSection = title;source = "Singer Songs" } label: { + Label(title,systemImage:icon).font(.system(size:11)).foregroundStyle(title == "Favourites" ? Color(red:1,green:0.75,blue:0.16) : Color.white.opacity(0.75)).padding(.leading,title == "All Songs" ? 0 : 43).frame(maxWidth:.infinity,alignment:.leading).frame(height:25) + }.buttonStyle(.plain) + } + private var singerSongPane: some View { + VStack(alignment:.leading,spacing:12) { + if let singer = model.singers.first(where:{ $0.id == model.selectedSinger }) { + HStack { Text("\(singer.name) · \(singerSection)").font(.headline);Spacer();Button("Add Songs…") { songPickerSinger = singer } } + Text("\(model.currentVenueName) · Singer song lists follow them across venues.").font(.caption).foregroundStyle(.secondary) + if singerSection == "History" { + ScrollView { LazyVStack(alignment:.leading,spacing:12) { ForEach(model.history.filter { $0.singerID == singer.id }) { row in HStack { Text(row.date).font(.caption.monospaced());Text(row.song);Spacer() } } } } + } else if singerSection == "Tonight" { + ScrollView { VStack(spacing:10) { ForEach(model.queue.filter { $0.singerID == singer.id }) { row in HStack { Text(row.title);Spacer();Button("Play") { model.perform { if let song = model.songs.first(where:{ $0.id == row.songID }) { try model.play(song,singer:singer.id);try model.library?.removeQueue(row.id);try model.refresh() } } };Button("Remove") { model.removeQueue(row.id) } } };if model.queue.filter({ $0.singerID == singer.id }).isEmpty { Text("No songs queued for this singer at this venue.").foregroundStyle(.secondary) } } } + } else { + ScrollView { + VStack(spacing:10) { + ForEach(model.songs(for:singer.id).filter { singerSection != "Favourites" || model.favourites[singer.id]?.contains($0.id) == true }) { song in + HStack { + Button { model.setFavourite(song.id,singer:singer.id,included:model.favourites[singer.id]?.contains(song.id) != true) } label: { Image(systemName:model.favourites[singer.id]?.contains(song.id) == true ? "star.fill" : "star").foregroundStyle(Color(red:1,green:0.75,blue:0.16)) }.buttonStyle(.plain).help("Favourite for this singer") + Text(song.title).lineLimit(1);Spacer() + Button("Play") { model.selectedSinger = singer.id;model.perform { try model.play(song,singer:singer.id) } } + Button("Tonight") { model.selectedSinger = singer.id;model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Button("Remove") { model.assign(song.id,to:singer.id,included:false) } + }.padding(8).background(.white.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = song.id } + } + if model.songs(for:singer.id).isEmpty { Text("Use Add Songs… or the + beside this singer in the sidebar.").foregroundStyle(.secondary).padding(.top,25) } + } + } + } + } else { Text("Choose a singer from the sidebar.").foregroundStyle(.secondary);Spacer() } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + private var songTable: some View { + GeometryReader { geometry in + ScrollView(.horizontal) { + VStack(spacing: 0) { + tableHeader(width: max(geometry.size.width, 960)) + ScrollView(.vertical) { + LazyVStack(spacing: 0) { + ForEach(visibleSongs) { song in songRow(song, width: max(geometry.size.width, 960)) } + }.frame(maxWidth: .infinity, alignment: .topLeading) + } + }.frame(width: max(geometry.size.width, 960), height: geometry.size.height) + } + }.background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) + } + private func widths(_ total: CGFloat) -> [CGFloat] { [44,max(220,total-600),110,100,65,82,90,109] } + private func tableHeader(width: CGFloat) -> some View { + let sizes = widths(width) + return HStack(spacing:0) { + ForEach(Array(["ID","Song Name","Artist","Album","Duration","Pitch","Tempo","Rating"].enumerated()),id:\.offset) { index,title in + Button { + guard ["Song Name","Artist","Album","Duration","Rating"].contains(title) else { return } + if librarySort == title { sortAscending.toggle() } else { librarySort = title;sortAscending = true } + } label: { HStack(spacing:3) { Text(title);if librarySort == title { Image(systemName:sortAscending ? "chevron.up" : "chevron.down").font(.system(size:8)) } }.font(.system(size:12,weight:.medium)).lineLimit(1).padding(.leading,7).frame(width:sizes[index],height:30,alignment:.leading) } + .buttonStyle(.plain).overlay(alignment:.trailing) { Rectangle().fill(Classic.line).frame(width:1).padding(.vertical,4) } + } + }.foregroundStyle(.white).background(Classic.header).overlay(alignment:.bottom) { Rectangle().fill(Color(white:0.65)).frame(height:1) } + } + private func durationLabel(_ seconds: Double) -> String { seconds > 0 ? String(format:"%d:%02d",Int(seconds)/60,Int(seconds)%60) : "—" } + private func songRow(_ song: Song,width: CGFloat) -> some View { + let sizes = widths(width) + return HStack(spacing:0) { + Text("\(song.id)").foregroundStyle(.secondary).frame(width:sizes[0]) + Text(song.title).lineLimit(1).padding(.leading,7).frame(width:sizes[1],alignment:.leading) + Text(song.artist.isEmpty ? "—" : song.artist).foregroundStyle(.secondary).lineLimit(1).padding(.leading,7).frame(width:sizes[2],alignment:.leading) + Text(song.album.isEmpty ? "—" : song.album).foregroundStyle(.secondary).lineLimit(1).padding(.leading,7).frame(width:sizes[3],alignment:.leading) + Text(durationLabel(song.duration)).font(.system(size:11,design:.monospaced)).frame(width:sizes[4]) + HStack(spacing:2) { + Text(String(format:"%+.0f",model.preference(song.id).pitch)).font(.system(size:11,design:.monospaced)).frame(width:30) + Stepper("Pitch",value:Binding(get:{ model.preference(song.id).pitch },set:{ model.updateTuning(song.id,pitch:$0) }),in:-12...12,step:1).labelsHidden().controlSize(.mini).fixedSize() + }.frame(width:sizes[5]).help("Saved pitch; applies to the selected singer, or song default") + Picker("Tempo",selection:Binding(get:{ model.preference(song.id).tempo },set:{ model.updateTuning(song.id,tempo:$0) })) { + ForEach(10..<31) { Text("\($0*5)%").tag(Double($0)/20) } + }.labelsHidden().controlSize(.mini).frame(width:sizes[6]-8).padding(.horizontal,4).help("Saved tempo; applies live if this song is playing") + Menu { ForEach(0..<6) { value in Button(value == 0 ? "Unrated" : String(repeating:"★",count:value)) { model.perform { try model.library?.rate(song.id,value);try model.refresh() } } } } label: { + Text(song.rating == 0 ? "☆☆☆☆☆" : String(repeating:"★",count:song.rating)+String(repeating:"☆",count:5-song.rating)).font(.system(size:12)).foregroundStyle(song.rating == 0 ? Color.gray : Color(red:1,green:0.75,blue:0.16)) + }.menuStyle(.borderlessButton).frame(width:sizes[7]-14).padding(.horizontal,7) + }.font(.system(size:12)).foregroundStyle(.white).frame(height:30) + .background(selected == song.id ? Color.blue.opacity(0.5) : song.id % 2 == 0 ? Color.white.opacity(0.025) : .clear) + .contentShape(Rectangle()).onTapGesture(count:2) { selected = song.id;playSelected() }.onTapGesture { selected = song.id } + .contextMenu { + Button("Play") { selected = song.id; playSelected() } + Button("Edit Metadata…") { editingSong = song } + Menu("Add to Singer") { + ForEach(model.venueSingers) { singer in Button(singer.name) { model.assign(song.id,to:singer.id);model.selectedSinger = singer.id;expandedSingers.insert(singer.id) } } + Divider();Button("New Singer + This Song…") { model.beginSinger(song:song.id) } + } + Button("Add to Current Venue Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Menu("Add to Playlist") { ForEach(model.playlists) { playlist in Button(playlist.name) { model.addToPlaylist(playlist.id,song:song.id) } } } + } + } + @State private var managingRoster = false + @State private var rosterNewName = "" + @State private var editingEntity: PersonPlace? + @State private var renameText = "" + @State private var pendingDelete: PersonPlace? + private var directory: some View { + VStack(alignment:.leading,spacing:0) { + HStack { + Text(source == "Singers" ? "Singers · \(model.currentVenueName)" : "Venues").font(.headline) + Spacer() + if source == "Singers" { Button("Manage Venue Singers…") { managingRoster = true }.disabled(model.currentVenue == nil) } + Button(source == "Singers" ? "New Singer…" : "New Venue…") { if source == "Singers" { model.beginSinger() } else { model.creating = .venue } } + }.padding(12).background(Classic.header) + ScrollView { + VStack(alignment:.leading,spacing:0) { + if source == "Singers" && model.venueSingers.isEmpty { + Text("No singers at this venue. Add a singer or use Manage Venue Singers to include existing singers.").foregroundStyle(.secondary).padding() + } + ForEach(source == "Singers" ? model.venueSingers : model.venues) { entity in + HStack { + Button { + if source == "Singers" { model.selectedSinger = entity.id } else { model.chooseVenue(entity.id) } + } label: { + HStack { + Image(systemName:source == "Singers" ? "person.fill" : "building.2.fill") + Text(entity.name); Spacer() + if entity.id == (source == "Singers" ? model.selectedSinger : model.currentVenue) { + Text(source == "Singers" ? "Selected" : "Current venue").font(.caption).foregroundStyle(.cyan) + Image(systemName:"checkmark.circle.fill").foregroundStyle(.cyan) + } + }.contentShape(Rectangle()) + }.buttonStyle(.plain) + Button("Rename") { renameText = entity.name; editingEntity = entity } + Button("Delete",role:.destructive) { pendingDelete = entity } + }.padding(12) + } + } + } + HStack { + TextField(source == "Singers" ? "Singer name" : "Venue name",text:$name).textFieldStyle(.roundedBorder).onSubmit(addEntity) + Button(source == "Singers" ? "Add Singer" : "Add Venue",action:addEntity).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + }.padding(12) + }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + .sheet(isPresented:$managingRoster) { rosterSheet } + .sheet(item:$editingEntity) { entity in + VStack(alignment:.leading,spacing:16) { + Text("Rename \(source == "Singers" ? "Singer" : "Venue")").font(.headline) + TextField("Name",text:$renameText).textFieldStyle(.roundedBorder) + HStack { Button("Cancel") { editingEntity = nil }; Spacer(); Button("Save") { model.rename(source == "Singers" ? "singers" : "venues",id:entity.id,name:renameText); editingEntity = nil }.disabled(renameText.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) } + }.padding(24).frame(width:360) + } + .confirmationDialog("Delete \(pendingDelete?.name ?? "entry")? Historical plays are kept. Venue memberships and queued entries for it will be removed.",isPresented:Binding(get:{ pendingDelete != nil },set:{ if !$0 { pendingDelete = nil } })) { + Button("Delete",role:.destructive) { if let entity = pendingDelete { model.delete(source == "Singers" ? "singers" : "venues",id:entity.id) }; pendingDelete = nil } + } + } + private var rosterSheet: some View { + VStack(alignment:.leading,spacing:16) { + Text("Singers at \(model.currentVenueName)").font(.title2.bold()) + Text("A singer can belong to any number of venues. Unchecking removes them from this venue only.").foregroundStyle(.secondary) + ScrollView { + VStack(alignment:.leading,spacing:12) { + ForEach(model.singers) { singer in + Toggle(singer.name,isOn:Binding(get:{ model.venueSingers.contains { $0.id == singer.id } },set:{ model.membership(singer:singer.id,included:$0) })) + } + }.frame(maxWidth:.infinity,alignment:.leading) + }.frame(minHeight:120,maxHeight:280) + HStack { + TextField("New singer name",text:$rosterNewName).textFieldStyle(.roundedBorder) + Button("Add") { model.perform { _ = try model.createEntity(.singer,name:rosterNewName); rosterNewName = "" } }.disabled(rosterNewName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + Spacer(); Button("Done") { managingRoster = false }.keyboardShortcut(.defaultAction) + } + }.padding(24).frame(width:460) + } + private var detailPanel: some View { + VStack(alignment:.leading,spacing:9) { + HStack(spacing:10) { + Picker("Current venue",selection:Binding(get:{ model.currentVenue },set:{ model.chooseVenue($0) })) { + Text("Select venue…").tag(nil as Int64?) + ForEach(model.venues) { Text($0.name).tag(Optional($0.id)) } + } + Button { model.creating = .venue } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add venue") + Picker("Singer",selection:$model.selectedSinger) { + Text("None").tag(nil as Int64?) + ForEach(model.venueSingers) { Text($0.name).tag(Optional($0.id)) } + } + Button { model.beginSinger() } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add singer") + Menu("Add to Singer") { + ForEach(model.venueSingers) { singer in Button(singer.name) { if let selected { model.assign(selected,to:singer.id);model.selectedSinger = singer.id;expandedSingers.insert(singer.id) } } } + Divider();Button("New Singer + Song…") { model.beginSinger(song:selected) } + }.disabled(selected == nil) + Button("Queue") { if let selected { model.enqueue(selected) } }.disabled(selected == nil || model.currentVenue == nil) + } + PlaybackSettings(player:model.player,model:model,selectedSong:selected) + SeekBar(player:model.player) + }.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity) + .background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + @State private var playlistName = "" + @State private var selectedPlaylist: Int64? + private var queuePane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { Text("Queue · \(model.currentVenueName)").font(.headline); Spacer(); Toggle("Auto play next",isOn:$model.autoAdvance).toggleStyle(.checkbox); Button("Play Next") { model.playQueueNext() }.disabled(model.queue.isEmpty) } + ScrollView { + VStack(spacing:0) { + ForEach(Array(model.queue.enumerated()),id:\.element.id) { index,row in + HStack { + Text("\(index+1)").foregroundStyle(.secondary).frame(width:24) + Text(row.singer).frame(width:140,alignment:.leading) + Text(row.title).lineLimit(1); Spacer() + Button { model.moveQueue(row.id,by:-1) } label: { Image(systemName:"arrow.up") }.disabled(index == 0) + Button { model.moveQueue(row.id,by:1) } label: { Image(systemName:"arrow.down") }.disabled(index == model.queue.count-1) + Button("Remove") { model.removeQueue(row.id) } + }.padding(.vertical,8) + } + if model.queue.isEmpty { Text("Choose a venue and singer, select a library song, then click Queue Song.").foregroundStyle(.secondary).padding(.top,30) } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + @State private var historyAllVenues = false + private var displayedHistory: [Performance] { + if historyAllVenues { return (try? model.library?.history(venue:nil,singer:model.selectedSinger)) ?? [] } + return model.history.filter { model.selectedSinger == nil || $0.singer == model.singers.first(where: { $0.id == model.selectedSinger })?.name } + } + private var historyPane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { Text("History · \(historyAllVenues || model.currentVenue == nil ? "All venues" : model.currentVenueName)").font(.headline); Spacer(); Toggle("All venues",isOn:$historyAllVenues).toggleStyle(.checkbox) } + ScrollView { + LazyVStack(alignment:.leading,spacing:12) { + ForEach(displayedHistory) { row in + HStack { Text(row.date).font(.system(.caption,design:.monospaced)); Text(row.singer).frame(width:130,alignment:.leading); Text(row.song); Spacer(); Text(row.venue).foregroundStyle(.secondary) } + } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + private var playlistPane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { + Picker("Playlist",selection:$selectedPlaylist) { Text("Choose…").tag(nil as Int64?); ForEach(model.playlists) { Text($0.name).tag(Optional($0.id)) } } + TextField("New playlist name",text:$playlistName).textFieldStyle(.roundedBorder) + Button("Create") { model.createPlaylist(playlistName); selectedPlaylist = model.playlists.first { $0.name == playlistName.trimmingCharacters(in:.whitespacesAndNewlines) }?.id; playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + if let selectedPlaylist { + Button("Rename") { model.rename("playlists",id:selectedPlaylist,name:playlistName); playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + Button("Delete",role:.destructive) { model.delete("playlists",id:selectedPlaylist); self.selectedPlaylist = nil } } + } + ScrollView { + if let selectedPlaylist { + let ids = (try? model.library?.playlistSongIDs(selectedPlaylist)) ?? [] + VStack(alignment:.leading,spacing:10) { + ForEach(ids,id:\.self) { id in + if let song = model.songs.first(where: { $0.id == id }) { + HStack { + Text(song.title); Spacer() + Button("Play") { model.perform { try model.play(song) } } + Button("Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Button("Remove") { model.perform { try model.library?.removeFromPlaylist(selectedPlaylist,song:id); try model.refresh() } } + } + } + } + } + } + Text("Add songs using the library row’s context menu.").foregroundStyle(.secondary).padding(.top,20) + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + private var footer: some View { + HStack { + Button { importing = true } label: { Image(systemName: "plus").font(.system(size: 20, weight: .bold)).frame(width: 32, height: 24) } + .help("Import audio, MP3+G or ZIP").accessibilityLabel("Import songs") + RepeatButton(player: model.player).padding(.leading, 12) + Button(model.readingDurations ? "Reading…" : "Read Durations") { model.scanDurations() }.controlSize(.small).disabled(model.readingDurations).help("Read durations for songs imported by older versions") + Button("Edit Metadata…") { editingSong = selectedSong }.controlSize(.small).disabled(selectedSong == nil) + Spacer() + Text("\(visibleSongs.count) \(visibleSongs.count == 1 ? "Song" : "Songs")").font(.system(size: 14, weight: .medium)) + Spacer() + Button { openWindow(id: "lyrics") } label: { Image(systemName: "rectangle.on.rectangle").frame(width: 32, height: 24) } + .help("Open video window").accessibilityLabel("Open video window") + }.buttonStyle(SilverSquareStyle()) + } + private func playSelected() { + guard let song = selectedSong else { return } + model.perform { try model.play(song) } + } + private func playNext() { + guard !visibleSongs.isEmpty else { return } + let index = visibleSongs.firstIndex { $0.id == selected } ?? -1 + selected = visibleSongs[(index + 1) % visibleSongs.count].id; playSelected() + } + private func addEntity() { + model.perform { + let kind: EntityKind = source == "Singers" ? .singer : .venue + let id = try model.createEntity(kind, name: name) + if kind == .singer { model.selectedSinger = id } else { model.chooseVenue(id) } + name = "" + } + } +} +private struct SongMetadataSheet: View { + @ObservedObject var model: AppModel + let song: Song + @Environment(\.dismiss) private var dismiss + @State private var title = "" + @State private var artist = "" + @State private var album = "" + @State private var label = "" + @State private var rating = 0 + @State private var error: String? + var body: some View { + VStack(alignment:.leading,spacing:14) { + Text("Edit Song Metadata").font(.title2.bold()) + Grid(alignment:.leading,horizontalSpacing:12,verticalSpacing:10) { + GridRow { Text("Title");TextField("Title",text:$title) } + GridRow { Text("Artist");TextField("Artist",text:$artist) } + GridRow { Text("Album");TextField("Album",text:$album) } + GridRow { Text("Label");TextField("Label / publisher",text:$label) } + GridRow { Text("Rating");Picker("Rating",selection:$rating) { ForEach(0..<6) { Text($0 == 0 ? "Unrated" : String(repeating:"★",count:$0)).tag($0) } }.labelsHidden() } + }.textFieldStyle(.roundedBorder) + Text("Saved to your library. Embedded audio-file tags are not changed, and re-importing will preserve your library edits.").font(.caption).foregroundStyle(.secondary) + Text(URL(fileURLWithPath:song.audio).lastPathComponent).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle).help(song.audio) + if let error { Text(error).foregroundStyle(.red).font(.callout) } + HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction);Button("Save") { save() }.keyboardShortcut(.defaultAction).disabled(title.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) } + }.padding(24).frame(width:500).background(Classic.frame).preferredColorScheme(.dark) + .onAppear { title = song.title;artist = song.artist;album = song.album;label = song.label;rating = song.rating } + } + private func save() { do { try model.updateMetadata(song,title:title,artist:artist,album:album,label:label,rating:rating);dismiss() } catch { self.error = error.localizedDescription } } +} +private struct MusicStoresPane: View { + @ObservedObject var model: AppModel + @Binding var selected: Int64? + @Environment(\.openURL) private var openURL + @State private var editor = false + @State private var editing: MusicStore? + @State private var search = "" + @State private var deleting: Int64? + var body: some View { + VStack(alignment:.leading,spacing:14) { + HStack { Text("Music Stores").font(.headline);Spacer();Button("Add Store…") { editing = nil;editor = true } } + Text("Define websites and optional catalogue-search URLs. Purchases and subscriptions are handled by the store in your browser.").font(.callout).foregroundStyle(.secondary) + HStack { TextField("Search store catalogue",text:$search).textFieldStyle(.roundedBorder);Text("{query} in a store’s search URL is replaced with this text.").font(.caption).foregroundStyle(.secondary) } + ScrollView { + VStack(alignment:.leading,spacing:12) { + ForEach(model.musicStores) { store in + VStack(alignment:.leading,spacing:8) { + HStack { Image(systemName:"bag.fill").foregroundStyle(.green);Text(store.name).font(.headline);Spacer() + Button("Open Store") { open(store,query:"") } + Button("Search") { open(store,query:search) }.disabled(search.isEmpty || store.searchTemplate.isEmpty) + Button("Edit") { editing = store;editor = true } + Button("Delete",role:.destructive) { deleting = store.id } + } + Text(store.url).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + }.padding(12).background(selected == store.id ? Color.blue.opacity(0.15) : Color.white.opacity(0.035)).contentShape(Rectangle()).onTapGesture { selected = store.id } + } + if model.musicStores.isEmpty { Text("No stores configured. Add a store name and website address to get started.").foregroundStyle(.secondary).padding(.top,20) } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + .sheet(isPresented:$editor) { MusicStoreEditor(model:model,store:editing) } + .confirmationDialog("Delete this store definition?",isPresented:Binding(get:{ deleting != nil },set:{ if !$0 { deleting = nil } })) { + Button("Delete",role:.destructive) { if let deleting { model.deleteStore(deleting) };deleting = nil } + } + } + private func open(_ store: MusicStore,query: String) { + do { openURL(try store.destination(query:query)) } catch { model.error = error.localizedDescription } + } +} +private struct MusicStoreEditor: View { + @ObservedObject var model: AppModel + let store: MusicStore? + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var url = "" + @State private var search = "" + @State private var error: String? + var body: some View { + VStack(alignment:.leading,spacing:14) { + Text(store == nil ? "Add Music Store" : "Edit Music Store").font(.title2.bold()) + TextField("Store name",text:$name).textFieldStyle(.roundedBorder) + TextField("Website URL — https://example.com",text:$url).textFieldStyle(.roundedBorder) + TextField("Optional search URL — https://example.com/search?q={query}",text:$search).textFieldStyle(.roundedBorder) + Text("Use {query} where the search words belong. Only http:// and https:// websites are accepted.").font(.caption).foregroundStyle(.secondary) + if let error { Text(error).foregroundStyle(.red).font(.callout) } + HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction);Button("Save") { save() }.keyboardShortcut(.defaultAction).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty || url.isEmpty) } + }.padding(24).frame(width:500).background(Classic.frame).preferredColorScheme(.dark) + .onAppear { name = store?.name ?? "";url = store?.url ?? "";search = store?.searchTemplate ?? "" } + } + private func save() { do { try model.saveStore(.init(id:store?.id ?? 0,name:name,url:url,searchTemplate:search));dismiss() } catch { self.error = error.localizedDescription } } +} +private struct SingerSongsSheet: View { + @ObservedObject var model: AppModel + let singer: PersonPlace + @Environment(\.dismiss) private var dismiss + @State private var search = "" + @State private var importing = false + var body: some View { + VStack(alignment:.leading,spacing:14) { + Text("Songs for \(singer.name)").font(.title2.bold()) + Text("Tick songs to add them to this singer. Unticking removes only the assignment.").font(.callout).foregroundStyle(.secondary) + TextField("Search library",text:$search).textFieldStyle(.roundedBorder) + ScrollView { + LazyVStack(alignment:.leading,spacing:12) { + ForEach(model.songs.filter { search.isEmpty || $0.title.localizedCaseInsensitiveContains(search) }) { song in + Toggle(song.title,isOn:Binding(get:{ model.singerSongIDs[singer.id]?.contains(song.id) == true },set:{ model.assign(song.id,to:singer.id,included:$0) })).toggleStyle(.checkbox) + } + if model.songs.isEmpty { Text("Your library is empty. Import audio or MP3+G below.").foregroundStyle(.secondary) } + }.frame(maxWidth:.infinity,alignment:.leading) + }.frame(height:270) + HStack { Button("Import Songs…") { importing = true };Spacer();Button("Done") { dismiss() }.keyboardShortcut(.defaultAction) } + }.padding(24).frame(width:520).background(Classic.frame).preferredColorScheme(.dark) + .fileImporter(isPresented:$importing,allowedContentTypes:[.audio,.zip,UTType(filenameExtension:"cdg") ?? .data],allowsMultipleSelection:true) { result in + switch result { case .success(let urls):model.importFiles(urls);case .failure(let error):model.error = error.localizedDescription } + } + } +} +private struct AddEntitySheet: View { + @ObservedObject var model: AppModel + let kind: EntityKind + let saved: (Int64) -> Void + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var error: String? + @State private var venue: Int64? + @State private var songs = Set() + @State private var search = "" + @FocusState private var focused: Bool + var body: some View { + VStack(alignment:.leading,spacing:16) { + Text(kind == .singer ? "New Singer + Songs" : "New Venue").font(.title2.bold()) + TextField("\(kind.title) name",text:$name).textFieldStyle(.roundedBorder).focused($focused).onSubmit(save) + if kind == .singer { + Picker("Venue",selection:$venue) { Text("Choose a venue…").tag(nil as Int64?);ForEach(model.venues) { Text($0.name).tag(Optional($0.id)) } } + Text(venue == nil ? "Create a venue first, then add a singer to it." : "This singer will join the selected venue and become active.").font(.caption).foregroundStyle(.secondary) + Text("Add songs now (optional)").font(.headline) + TextField("Search library",text:$search).textFieldStyle(.roundedBorder) + ScrollView { + LazyVStack(alignment:.leading,spacing:10) { + ForEach(model.songs.filter { search.isEmpty || $0.title.localizedCaseInsensitiveContains(search) }) { song in + Toggle(song.title,isOn:Binding(get:{ songs.contains(song.id) },set:{ if $0 { songs.insert(song.id) } else { songs.remove(song.id) } })).toggleStyle(.checkbox) + } + if model.songs.isEmpty { Text("No library songs yet. You can add songs from the singer’s + button after importing.").font(.callout).foregroundStyle(.secondary) } + }.frame(maxWidth:.infinity,alignment:.leading) + }.frame(height:190) + } + if let error { Text(error).foregroundStyle(.red).font(.callout) } + HStack { Spacer();Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Button(kind == .singer ? "Add Singer & Songs" : "Add Venue",action:save).keyboardShortcut(.defaultAction) + .disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty || (kind == .singer && venue == nil)) + } + }.padding(24).frame(width:kind == .singer ? 500 : 360).background(Classic.frame).preferredColorScheme(.dark) + .onAppear { focused = true;venue = model.currentVenue ?? model.venues.first?.id;songs = model.newSingerSongs } + } + private func save() { + do { + let id: Int64 + if kind == .singer { guard let venue else { throw LibraryError.message("Choose a venue") };id = try model.createSinger(name:name,venue:venue,songs:songs) } + else { id = try model.createEntity(kind,name:name) } + saved(id);dismiss() + } catch { self.error = error.localizedDescription } + } +} +private struct TransportView: View { + @ObservedObject var player: Player + @Binding var search: String + let hasSelection: Bool + let play: () -> Void + let next: () -> Void + let display: () -> Void + var body: some View { + HStack(alignment: .top, spacing: 28) { + VStack(spacing: 15) { + HStack(spacing: 7) { + SilverButton(symbol: "backward.fill", label: "Back 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time - 10) } + SilverButton(symbol: player.playing ? "pause.fill" : "play.fill", label: player.playing ? "Pause" : "Play", size: 49, enabled: hasSelection || player.duration > 0) { + if player.duration > 0 { player.toggle() } else { play() } + } + SilverButton(symbol: "stop", label: "Stop", size: 46, enabled: player.duration > 0) { player.stop() } + SilverButton(symbol: "forward.fill", label: "Forward 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time + 10) } + SilverButton(symbol: "forward.end.fill", label: "Play next song", size: 26, enabled: hasSelection, action: next) + } + HStack(spacing: 9) { + Image(systemName: "speaker.fill").font(.system(size: 11)) + Slider(value: $player.volume, in: 0...1).controlSize(.small).tint(.gray).accessibilityLabel("Volume") + Image(systemName: "speaker.wave.3.fill").font(.system(size: 12)) + }.frame(width: 180) + }.frame(width: 220) + NowPlayingWaveform(player: player).padding(.top, 4) + HStack(spacing: 12) { + TextField("Search", text: $search).textFieldStyle(.plain).font(.system(size: 16)) + .padding(.horizontal, 8).frame(width: 145, height: 28).background(Color(white: 0.07), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).stroke(.white.opacity(0.1))) + SilverButton(symbol: "eye", label: "Open video window", size: 48, action: display) + }.padding(.top, 15) + }.foregroundStyle(.white).frame(height: 130) + } +} +private struct NowPlayingWaveform: View { + @ObservedObject var player: Player + @State private var mixer = false + @State private var zoom: Double = 1 + @State private var hoverX: CGFloat? + @State private var scrubStart: Double? + @State private var resumeAfterScrub = false + @State private var scrubSong: Int64? + private var span: Double { max(0.001,player.duration/zoom) } + private var windowStart: Double { scrubStart ?? min(max(0,player.time-span/2),max(0,player.duration-span)) } + private var progress: Double { player.duration > 0 ? min(1,max(0,(player.time-windowStart)/span)) : 0 } + private func clock(_ value: Double) -> String { let n = max(0,Int(value));return String(format:"%d:%02d",n/60,n%60) } + private func preciseClock(_ value: Double) -> String { String(format:"%d:%05.2f",max(0,Int(value))/60,max(0,value).truncatingRemainder(dividingBy:60)) } + private func bandColor(_ index: Int) -> Color { + guard player.waveformBands.indices.contains(index) else { return .cyan } + let band = player.waveformBands[index] + let largest = max(0.000001,max(band.low,max(band.mid,band.high))) + let low = Double(band.low/largest),mid = Double(band.mid/largest),high = Double(band.high/largest) + return Color(red:min(1,pow(low,1.2)+mid*0.12+high*0.6),green:min(1,low*0.08+pow(mid,1.5)*0.97+high*0.1),blue:min(1,low*0.07+mid*0.22+high)) + } + var body: some View { + VStack(alignment:.leading,spacing:5) { + Text(player.duration > 0 ? player.title : "Ready to play").font(.system(size:13,weight:.semibold)).foregroundStyle(Color(white:0.94)) + .lineLimit(1).truncationMode(.middle).frame(maxWidth:.infinity,alignment:.leading).help(player.title) + GeometryReader { geometry in + Canvas { context,size in + let top: CGFloat = 10,plotHeight = size.height-top,mid = top+plotHeight/2 + var centre = Path();centre.move(to:CGPoint(x:0,y:mid));centre.addLine(to:CGPoint(x:size.width,y:mid)) + context.stroke(centre,with:.color(.white.opacity(0.13)),lineWidth:0.5) + if player.duration > 0 { + let step = [0.1,0.25,0.5,1.0,2,5,10,15,30,60,120,300,600,1800,3600].first { $0 >= span/5 } ?? 3600 + let first = floor(windowStart/(step/4))*(step/4) + for t in stride(from:first,through:min(player.duration,windowStart+span),by:step/4) { + let x = size.width*(t-windowStart)/span + if x < 0 { continue } + let major = abs(t/step-(t/step).rounded()) < 0.001 + var tick = Path();tick.move(to:CGPoint(x:x,y:top));tick.addLine(to:CGPoint(x:x,y:size.height)) + context.stroke(tick,with:.color(major ? .yellow.opacity(0.65) : .white.opacity(0.16)),lineWidth:major ? 0.8 : 0.5) + if major && x < size.width-25 { + context.draw(Text(span < 10 ? preciseClock(t) : clock(t)).font(.system(size:8,design:.monospaced)).foregroundColor(.gray),at:CGPoint(x:x+3,y:3),anchor:.leading) + } + } + } + if !player.waveform.isEmpty { + let count = max(1,Int(size.width/2)),step = size.width/CGFloat(count) + for bar in 0.. 0,geometry.size.width > 0 else { return } + if scrubStart == nil { + scrubStart = windowStart;scrubSong = player.currentSongID;resumeAfterScrub = player.playing + if player.playing { player.toggle() } + } + guard scrubSong == player.currentSongID else { return } + hoverX = value.location.x + let target = windowStart+Double(min(1,max(0,value.location.x/geometry.size.width)))*span + if abs(target-player.time) > 0.02 { player.seek(target) } + }.onEnded { value in + if scrubStart == nil { + if player.duration > 0 { player.seek(windowStart+Double(min(1,max(0,value.location.x/max(1,geometry.size.width))))*span) } + return + } + let target = windowStart+Double(min(1,max(0,value.location.x/max(1,geometry.size.width))))*span + if scrubSong == player.currentSongID { + player.seek(target) + if resumeAfterScrub && !player.playing && target < player.duration-0.01 { player.toggle() } + } + scrubStart = nil;resumeAfterScrub = false;scrubSong = nil + }).help("Hover for exact time. Drag to scrub; playback resumes on release. Zoom for fine positioning.") + .accessibilityLabel("Audio waveform").accessibilityValue("\(clock(player.time)) of \(clock(player.duration))") + .accessibilityAdjustableAction { direction in if direction == .increment { player.seek(player.time+5) } else if direction == .decrement { player.seek(player.time-5) } } + }.frame(height:60) + HStack(spacing:5) { + Circle().fill(player.playing ? Color.green : Color.orange).frame(width:4,height:4) + Text(player.duration == 0 ? "NO TRACK LOADED" : player.playing ? "PLAYING" : "PAUSED").tracking(1) + if player.waveformUnavailable { Text("· Waveform unavailable").foregroundStyle(.secondary) } + Spacer(minLength:0) + Button { zoom = max(1,zoom/2) } label: { Image(systemName:"minus.magnifyingglass") }.buttonStyle(.plain).disabled(zoom <= 1).help("Zoom out") + Button { zoom = min(32,zoom*2) } label: { Image(systemName:"plus.magnifyingglass") }.buttonStyle(.plain).disabled(zoom >= 32).help("Zoom in for precise scrubbing") + Menu("\(Int(zoom))×") { ForEach([1.0,2,4,8,16,32],id:\.self) { value in Button("\(Int(value))× zoom") { zoom = value } } }.menuStyle(.borderlessButton).frame(width:34).help("Waveform zoom around the playhead") + Menu { Button("Re-analyse Waveform") { player.reanalyseWaveform() }.disabled(player.duration == 0) + Text(player.waveformLoading ? "Analysing…" : player.waveformFromCache ? "Loaded saved waveform" : player.waveformSaved ? "Analysis saved" : "No saved analysis") + } label: { Image(systemName:"waveform") }.menuStyle(.borderlessButton).frame(width:20).help("Waveform cache and re-analysis") + Button("EQ / TONE") { mixer.toggle() }.buttonStyle(.plain).foregroundStyle(.white.opacity(0.85)) + .padding(.horizontal,6).padding(.vertical,2).background(.white.opacity(0.08),in:RoundedRectangle(cornerRadius:3)) + .popover(isPresented:$mixer) { ToneMixerView(player:player) } + Text("\(clock(player.time)) / \(clock(player.duration))").monospacedDigit().padding(.leading,5) + }.font(.system(size:9,weight:.medium)).foregroundStyle(Color(white:0.6)) + }.padding(.horizontal,12).padding(.vertical,9).frame(maxWidth:.infinity).frame(height:120) + .background(Color(red:0.025,green:0.028,blue:0.035)).overlay(Rectangle().strokeBorder(Color(white:0.24),lineWidth:1)) + } +} +private struct ToneKnob: View { + let title: String + @Binding var value: Double + let range: ClosedRange + var filter = false + @State private var initial: Double? + private var fraction: Double { min(1,max(0,(value-range.lowerBound)/(range.upperBound-range.lowerBound))) } + private var display: String { + if filter { return abs(value) < 0.01 ? "OFF" : value < 0 ? "LOW PASS" : "HIGH PASS" } + return String(format:"%+.1f dB",value) + } + var body: some View { + VStack(spacing:4) { + ZStack { + Circle().trim(from:0,to:0.75).stroke(Color(white:0.21),style:StrokeStyle(lineWidth:4,lineCap:.round)).rotationEffect(.degrees(135)) + Circle().trim(from:0,to:0.75*fraction).stroke(Color(white:0.48),style:StrokeStyle(lineWidth:3,lineCap:.round)).rotationEffect(.degrees(135)) + Circle().fill(LinearGradient(colors:[Color(white:0.19),Color(white:0.09)],startPoint:.top,endPoint:.bottom)).padding(6).overlay(Circle().stroke(.black.opacity(0.65),lineWidth:1.5).padding(6)) + Capsule().fill(.white).frame(width:2,height:12).offset(y:-13).rotationEffect(.degrees(-135+270*fraction)) + }.frame(width:50,height:50).contentShape(Circle()) + .gesture(DragGesture(minimumDistance:0).onChanged { gesture in + if initial == nil { initial = value } + value = min(range.upperBound,max(range.lowerBound,(initial ?? value)-Double(gesture.translation.height)/130*(range.upperBound-range.lowerBound))) + }.onEnded { _ in initial = nil }) + .simultaneousGesture(TapGesture(count:2).onEnded { value = 0 }) + .accessibilityElement().accessibilityLabel(title).accessibilityValue(display) + .accessibilityAdjustableAction { direction in + let change = (range.upperBound-range.lowerBound)/24 + value = min(range.upperBound,max(range.lowerBound,value+(direction == .increment ? change : -change))) + }.help("Drag up/down to adjust. Double-click to reset.") + Text(title).font(.system(size:10,weight:.bold)).foregroundStyle(Color(white:0.6)) + Text(display).font(.system(size:8,design:.monospaced)).foregroundStyle(Color(white:0.45)) + } + } +} +private struct StereoMeter: View { + @ObservedObject var player: Player + private func normal(_ level: Float) -> Double { max(0,min(1,(20*log10(Double(max(level,0.000001)))+60)/60)) } + var body: some View { + HStack(spacing:3) { + ForEach(0..<2) { channel in + VStack(spacing:2) { + ForEach((0..<24).reversed(),id:\.self) { segment in + let on = Double(segment)/24 < normal(channel == 0 ? player.leftLevel : player.rightLevel) + Rectangle().fill(on ? (segment > 21 ? Color.red : segment > 17 ? Color.yellow : Color.green) : Color.black.opacity(0.5)) + } + } + } + }.frame(width:23,height:125).accessibilityLabel("Stereo output levels") + } +} +private struct MixerFader: View { + @Binding var value: Double + var body: some View { + GeometryReader { geometry in + let travel = geometry.size.height-22 + ZStack(alignment:.top) { + VStack { ForEach(0..<7) { _ in Rectangle().fill(.white.opacity(0.09)).frame(height:1);Spacer(minLength:0) } }.padding(.vertical,11) + Capsule().fill(.black.opacity(0.8)).frame(width:5).padding(.vertical,8) + RoundedRectangle(cornerRadius:5).fill(LinearGradient(colors:[Color(white:0.45),Color(white:0.23)],startPoint:.top,endPoint:.bottom)) + .frame(width:45,height:20).overlay(Rectangle().fill(.white.opacity(0.15)).frame(width:18,height:1)) + .offset(y:(1-value)*travel) + }.frame(maxWidth:.infinity).contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance:0).onChanged { value = min(1,max(0,1-Double(($0.location.y-11)/travel))) }) + }.frame(width:56,height:135).accessibilityLabel("Master volume").accessibilityValue("\(Int(value*100)) percent") + .accessibilityAdjustableAction { direction in value = min(1,max(0,value+(direction == .increment ? 0.05 : -0.05))) } + } +} +private struct ToneMixerView: View { + @ObservedObject var player: Player + var body: some View { + VStack(spacing:14) { + HStack { Text("TONE / MIXER").font(.system(size:12,weight:.bold));Spacer();Toggle("EQ",isOn:$player.toneEnabled).toggleStyle(.switch).controlSize(.mini) } + HStack(alignment:.top,spacing:28) { + VStack(spacing:8) { + ToneKnob(title:"HIGH",value:$player.highGain,range:-12...12) + ToneKnob(title:"MID",value:$player.midGain,range:-12...12) + ToneKnob(title:"LOW",value:$player.lowGain,range:-12...12) + } + VStack(spacing:15) { + ToneKnob(title:"FILTER",value:$player.filter,range:-1...1,filter:true) + HStack(spacing:20) { StereoMeter(player:player);MixerFader(value:$player.volume) } + Text(player.volume <= 0 ? "−∞ dB" : String(format:"%+.1f dB",20*log10(player.volume))).font(.system(size:10,design:.monospaced)).foregroundStyle(.secondary) + } + }.padding(.horizontal,15) + HStack { Text("Drag knobs · Double-click to reset").font(.system(size:9)).foregroundStyle(.secondary);Spacer();Button("Reset EQ") { player.resetTone() }.controlSize(.small) } + }.padding(18).frame(width:270).background(Color(white:0.105)).preferredColorScheme(.dark) + } +} +private struct PlaybackSettings: View { + @ObservedObject var player: Player + @ObservedObject var model: AppModel + let selectedSong: Int64? + @State private var personalRating = 0 + var body: some View { + HStack(spacing:10) { + Text("Key") + Picker("Key",selection:$player.pitch) { ForEach(-12..<13) { Text(String(format:"%+d",$0)).tag(Double($0)) } }.labelsHidden().frame(width:70) + Text("Tempo") + Slider(value:$player.tempo,in:0.5...1.5,step:0.05).frame(width:85) + Text(String(format:"%.0f%%",player.tempo*100)).monospacedDigit().frame(width:40) + Text("Rating") + Picker("Singer rating",selection:$personalRating) { ForEach(0..<6) { Text($0 == 0 ? "Unrated" : String(repeating:"★",count:$0)).tag($0) } }.labelsHidden().frame(width:88).help("Singer-specific song rating") + Button("Save for Singer") { + if let song = player.currentSongID ?? selectedSong { model.savePreference(song:song,value:.init(pitch:player.pitch,tempo:player.tempo,rating:personalRating)) } + }.disabled(model.selectedSinger == nil || (player.currentSongID == nil && selectedSong == nil)) + Menu { + Button("Reset key / tempo") { player.pitch = 0; player.tempo = 1 } + ForEach([-1.0,-0.5,0,0.5,1.0],id:\.self) { offset in Button(String(format:"Graphics offset %+.1fs",offset)) { player.graphicsOffset = offset } } + } label: { Image(systemName:"slider.horizontal.3") }.menuStyle(.borderlessButton).frame(width:22).help("Playback settings") + }.font(.system(size:11)) + .onChange(of:model.selectedSinger) { _ in loadRating() } + .onChange(of:player.currentSongID) { _ in loadRating() } + .onChange(of:selectedSong) { _ in loadRating() } + } + private func loadRating() { if let song = player.currentSongID ?? selectedSong { personalRating = model.preference(song).rating } else { personalRating = 0 } } +} +private struct SeekBar: View { + @ObservedObject var player: Player + private func clock(_ seconds: Double) -> String { let n = Int(seconds); return String(format: "%d:%02d", n / 60, n % 60) } + var body: some View { + HStack(spacing: 10) { + Text(clock(player.time)) + Slider(value: Binding(get: { player.time }, set: { player.seek($0) }), in: 0...max(1, player.duration)) + .controlSize(.mini).tint(.gray).disabled(player.duration == 0).accessibilityLabel("Playback position") + Text(clock(player.duration)) + }.font(.system(size: 10, design: .monospaced)).foregroundStyle(.white.opacity(0.65)) + } +} +private struct RepeatButton: View { + @ObservedObject var player: Player + var body: some View { + Button { player.repeating.toggle() } label: { + Image(systemName: "repeat").font(.system(size: 18, weight: .semibold)) + .foregroundStyle(player.repeating ? Color.blue : Color(white: 0.25)).frame(width: 32, height: 24) + }.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off") + } +} diff --git a/backups/before-tonight-fix-20261103-092811/KJamsClone/Library.swift b/backups/before-tonight-fix-20261103-092811/KJamsClone/Library.swift new file mode 100644 index 0000000..ded33f3 --- /dev/null +++ b/backups/before-tonight-fix-20261103-092811/KJamsClone/Library.swift @@ -0,0 +1,286 @@ +import Foundation +import SQLite3 + +struct Song: Identifiable, Hashable { + var id: Int64 + var title: String + var audio: String + var cdg: String? + var rating: Int + var plays: Int + var artist: String = "" + var album: String = "" + var label: String = "" + var duration: Double = 0 +} +struct MusicStore: Identifiable, Hashable { + var id: Int64 + var name: String + var url: String + var searchTemplate: String + func destination(query: String = "") throws -> URL { + let query = query.trimmingCharacters(in:.whitespacesAndNewlines) + let encoded = query.addingPercentEncoding(withAllowedCharacters:CharacterSet(charactersIn:"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")) ?? "" + let value = query.isEmpty || searchTemplate.isEmpty ? url : searchTemplate.replacingOccurrences(of:"{query}",with:encoded) + guard let result = URL(string:value), ["http","https"].contains(result.scheme?.lowercased() ?? ""), result.host?.isEmpty == false, result.user == nil, result.password == nil else { throw LibraryError.message("Use a valid http:// or https:// store URL without credentials") } + return result + } +} +struct PersonPlace: Identifiable, Hashable { var id: Int64; var name: String } +struct SongPreference: Equatable { + var pitch: Double = 0 + var tempo: Double = 1 + var rating: Int = 0 +} +struct Performance: Identifiable { + var id: Int64 + var song: String + var singer: String + var venue: String + var date: String + var singerID: Int64? + var songID: Int64? +} +struct QueueEntry: Identifiable { + var id: Int64 + var songID: Int64 + var singerID: Int64? + var title: String + var singer: String +} +enum LibraryError: LocalizedError { + case message(String) + var errorDescription: String? { if case .message(let s) = self { return s }; return nil } +} +final class Library { + private var db: OpaquePointer? + init(url: URL) throws { + guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw LibraryError.message("Cannot open library") } + try execute("PRAGMA foreign_keys=ON") + try execute("CREATE TABLE IF NOT EXISTS songs(id INTEGER PRIMARY KEY,title TEXT NOT NULL,audio TEXT UNIQUE NOT NULL,cdg TEXT,rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),plays INTEGER NOT NULL DEFAULT 0)") + try execute("CREATE TABLE IF NOT EXISTS singers(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS venues(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS history(id INTEGER PRIMARY KEY,song INTEGER REFERENCES songs(id),singer INTEGER REFERENCES singers(id),venue INTEGER REFERENCES venues(id),played TEXT DEFAULT CURRENT_TIMESTAMP)") + // Additive migration: preserve older libraries and infer existing memberships from history. + for column in ["artist", "album", "label"] { + if try !columns("songs").contains(column) { try execute("ALTER TABLE songs ADD COLUMN \(column) TEXT NOT NULL DEFAULT ''") } + } + try execute("CREATE TABLE IF NOT EXISTS venue_singers(venue INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,PRIMARY KEY(venue,singer))") + if try schemaVersion() < 3 { try execute("INSERT OR IGNORE INTO venue_singers SELECT DISTINCT venue,singer FROM history WHERE venue IS NOT NULL AND singer IS NOT NULL") } + try execute("CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT)") + try execute("CREATE TABLE IF NOT EXISTS preferences(song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5),rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),PRIMARY KEY(song,singer))") + try execute("CREATE TABLE IF NOT EXISTS queue(id INTEGER PRIMARY KEY,venue INTEGER REFERENCES venues(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER REFERENCES singers(id) ON DELETE CASCADE,position INTEGER NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS playlists(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS playlist_songs(playlist INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(playlist,song))") + try execute("CREATE TABLE IF NOT EXISTS singer_songs(singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(singer,song))") + if try schemaVersion() < 4 { + try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,MIN(id) FROM history WHERE singer IS NOT NULL AND song IS NOT NULL GROUP BY singer,song") + try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) SELECT singer,song,0 FROM preferences") + } + if try !columns("songs").contains("duration") { try execute("ALTER TABLE songs ADD COLUMN duration REAL NOT NULL DEFAULT 0") } + if try !columns("singer_songs").contains("favourite") { try execute("ALTER TABLE singer_songs ADD COLUMN favourite INTEGER NOT NULL DEFAULT 0 CHECK(favourite IN (0,1))") } + if try schemaVersion() < 5 { try execute("UPDATE singer_songs SET favourite=1 WHERE EXISTS(SELECT 1 FROM preferences WHERE preferences.singer=singer_songs.singer AND preferences.song=singer_songs.song AND preferences.rating>=4)") } + try execute("CREATE TABLE IF NOT EXISTS song_settings(song INTEGER PRIMARY KEY REFERENCES songs(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5))") + try execute("CREATE TABLE IF NOT EXISTS music_stores(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL,url TEXT NOT NULL,search_template TEXT NOT NULL DEFAULT '')") + if try !columns("songs").contains("metadata_edited") { try execute("ALTER TABLE songs ADD COLUMN metadata_edited INTEGER NOT NULL DEFAULT 0") } + try execute("PRAGMA user_version=6") + } + deinit { sqlite3_close(db) } + private func statement(_ sql: String, _ values: [String?]) throws -> OpaquePointer { + var s: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &s, nil) == SQLITE_OK, let s else { throw error() } + for (i,v) in values.enumerated() { + let code = v.map { sqlite3_bind_text(s, Int32(i + 1), $0, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) } ?? sqlite3_bind_null(s, Int32(i + 1)) + if code != SQLITE_OK { sqlite3_finalize(s); throw error() } + } + return s + } + private func error() -> LibraryError { .message(String(cString: sqlite3_errmsg(db))) } + func execute(_ sql: String, _ values: [String?] = []) throws { + let s = try statement(sql, values); defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_DONE else { throw error() } + } + func songs() throws -> [Song] { + let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label,duration FROM songs ORDER BY title COLLATE NOCASE", []); defer { sqlite3_finalize(s) } + var result = [Song]() + while true { + let code = sqlite3_step(s); if code == SQLITE_DONE { break }; guard code == SQLITE_ROW else { throw error() } + result.append(Song(id: sqlite3_column_int64(s,0), title: String(cString: sqlite3_column_text(s,1)), audio: String(cString: sqlite3_column_text(s,2)), cdg: sqlite3_column_text(s,3).map { String(cString:$0) }, rating: Int(sqlite3_column_int(s,4)), plays: Int(sqlite3_column_int(s,5)), artist: text(s,6), album: text(s,7), label: text(s,8),duration:sqlite3_column_double(s,9))) + } + return result + } + func add(title: String, audio: String, cdg: String?, artist: String = "", album: String = "", label: String = "", duration: Double = 0) throws { + try execute("INSERT INTO songs(title,audio,cdg,artist,album,label,duration) VALUES(?,?,?,?,?,?,?) ON CONFLICT(audio) DO UPDATE SET cdg=COALESCE(excluded.cdg,songs.cdg),artist=CASE WHEN songs.metadata_edited=0 AND excluded.artist!='' THEN excluded.artist ELSE songs.artist END,album=CASE WHEN songs.metadata_edited=0 AND excluded.album!='' THEN excluded.album ELSE songs.album END,label=CASE WHEN songs.metadata_edited=0 AND excluded.label!='' THEN excluded.label ELSE songs.label END,duration=CASE WHEN excluded.duration>0 THEN excluded.duration ELSE songs.duration END", [title,audio,cdg,artist,album,label,String(duration)]) + } + func rate(_ id: Int64, _ rating: Int) throws { try execute("UPDATE songs SET rating=? WHERE id=?", [String(rating),String(id)]) } + func entities(_ table: String) throws -> [PersonPlace] { + precondition(["singers","venues","playlists"].contains(table)) + let s = try statement("SELECT id,name FROM \(table) ORDER BY name", []); defer { sqlite3_finalize(s) } + var rows = [PersonPlace]() + while sqlite3_step(s) == SQLITE_ROW { rows.append(.init(id: sqlite3_column_int64(s,0), name: String(cString: sqlite3_column_text(s,1)))) } + return rows + } + func addEntity(_ table: String, name: String) throws { + precondition(["singers","venues","playlists"].contains(table)) + let name = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { throw LibraryError.message("Enter a name") } + try execute("INSERT OR IGNORE INTO \(table)(name) VALUES(?)", [name]) + } + func record(_ song: Int64, singer: Int64?, venue: Int64?) throws { + try execute("BEGIN IMMEDIATE") + do { + if let singer { try assignSong(song,singer:singer) } + try execute("INSERT INTO history(song,singer,venue) VALUES(?,?,?)", [String(song),singer.map(String.init),venue.map(String.init)]) + try execute("UPDATE songs SET plays=plays+1 WHERE id=?", [String(song)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK"); throw error } + } + + private func text(_ s: OpaquePointer, _ index: Int32) -> String { sqlite3_column_text(s,index).map { String(cString:$0) } ?? "" } + private func schemaVersion() throws -> Int { + let s = try statement("PRAGMA user_version",[]); defer { sqlite3_finalize(s) } + return sqlite3_step(s) == SQLITE_ROW ? Int(sqlite3_column_int(s,0)) : 0 + } + private func columns(_ table: String) throws -> Set { + let s = try statement("PRAGMA table_info(\(table))", []); defer { sqlite3_finalize(s) } + var result = Set(); while sqlite3_step(s) == SQLITE_ROW { result.insert(text(s,1)) }; return result + } + func members(of venue: Int64) throws -> [PersonPlace] { + let s = try statement("SELECT singers.id,singers.name FROM singers JOIN venue_singers ON singers.id=venue_singers.singer WHERE venue=? ORDER BY name", [String(venue)]); defer { sqlite3_finalize(s) } + var result = [PersonPlace](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1))) }; return result + } + func setMembership(singer: Int64, venue: Int64, included: Bool) throws { + try execute(included ? "INSERT OR IGNORE INTO venue_singers(venue,singer) VALUES(?,?)" : "DELETE FROM venue_singers WHERE venue=? AND singer=?", [String(venue),String(singer)]) + if !included { try execute("DELETE FROM queue WHERE venue=? AND singer=?",[String(venue),String(singer)]) } + } + func currentVenue() throws -> Int64? { + let s = try statement("SELECT venues.id FROM venues JOIN settings ON CAST(settings.value AS INTEGER)=venues.id WHERE settings.key='currentVenue'", []); defer { sqlite3_finalize(s) } + return sqlite3_step(s) == SQLITE_ROW ? sqlite3_column_int64(s,0) : nil + } + func selectVenue(_ id: Int64?) throws { + if let id { + guard try entities("venues").contains(where: { $0.id == id }) else { throw LibraryError.message("Venue no longer exists") } + } + try execute("INSERT INTO settings(key,value) VALUES('currentVenue',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",[id.map(String.init)]) + } + func rename(_ table: String, id: Int64, name: String) throws { + precondition(["singers","venues","playlists"].contains(table)) + let name = name.trimmingCharacters(in:.whitespacesAndNewlines) + guard !name.isEmpty else { throw LibraryError.message("Enter a name") } + try execute("UPDATE \(table) SET name=? WHERE id=?",[name,String(id)]) + } + func deleteEntity(_ table: String, id: Int64) throws { + precondition(["singers","venues","playlists"].contains(table)) + try execute("BEGIN IMMEDIATE") + do { + if table == "singers" { try execute("UPDATE history SET singer=NULL WHERE singer=?",[String(id)]) } + if table == "venues" { + try execute("UPDATE history SET venue=NULL WHERE venue=?",[String(id)]) + try execute("DELETE FROM settings WHERE key='currentVenue' AND value=?",[String(id)]) + } + try execute("DELETE FROM \(table) WHERE id=?",[String(id)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK"); throw error } + } + func preference(song: Int64, singer: Int64) throws -> SongPreference { + let s = try statement("SELECT pitch,tempo,rating FROM preferences WHERE song=? AND singer=?",[String(song),String(singer)]); defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_ROW else { return try defaultPreference(song:song) } + return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:Int(sqlite3_column_int(s,2))) + } + func savePreference(song: Int64, singer: Int64, value: SongPreference) throws { + try execute("INSERT INTO preferences(song,singer,pitch,tempo,rating) VALUES(?,?,?,?,?) ON CONFLICT(song,singer) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo,rating=excluded.rating",[String(song),String(singer),String(value.pitch),String(value.tempo),String(value.rating)]) + } + func history(venue: Int64?, singer: Int64? = nil) throws -> [Performance] { + let s = try statement("SELECT history.id,COALESCE(songs.title,'Deleted song'),COALESCE(singers.name,'—'),COALESCE(venues.name,'—'),played,history.singer,history.song FROM history LEFT JOIN songs ON songs.id=history.song LEFT JOIN singers ON singers.id=history.singer LEFT JOIN venues ON venues.id=history.venue WHERE (? IS NULL OR history.venue=?) AND (? IS NULL OR history.singer=?) ORDER BY history.id DESC",[venue.map(String.init),venue.map(String.init),singer.map(String.init),singer.map(String.init)]); defer { sqlite3_finalize(s) } + var result = [Performance](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),song:text(s,1),singer:text(s,2),venue:text(s,3),date:text(s,4),singerID:sqlite3_column_type(s,5) == SQLITE_NULL ? nil : sqlite3_column_int64(s,5),songID:sqlite3_column_type(s,6) == SQLITE_NULL ? nil : sqlite3_column_int64(s,6))) }; return result + } + func enqueue(song: Int64, singer: Int64?, venue: Int64?) throws { + guard let venue else { throw LibraryError.message("Select a current venue before adding to its queue") } + if let singer, try !members(of:venue).contains(where: { $0.id == singer }) { throw LibraryError.message("Add this singer to the current venue first") } + try execute("BEGIN IMMEDIATE") + do { + if let singer { try assignSong(song,singer:singer) } + try execute("INSERT INTO queue(venue,song,singer,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?))",[String(venue),String(song),singer.map(String.init),String(venue)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK");throw error } + } + func queue(venue: Int64?) throws -> [QueueEntry] { + guard let venue else { return [] } + let s = try statement("SELECT queue.id,queue.song,queue.singer,songs.title,COALESCE(singers.name,'No singer') FROM queue JOIN songs ON songs.id=queue.song LEFT JOIN singers ON singers.id=queue.singer WHERE queue.venue=? ORDER BY queue.position,queue.id",[String(venue)]); defer { sqlite3_finalize(s) } + var result = [QueueEntry](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),songID:sqlite3_column_int64(s,1),singerID:sqlite3_column_type(s,2) == SQLITE_NULL ? nil : sqlite3_column_int64(s,2),title:text(s,3),singer:text(s,4))) }; return result + } + func removeQueue(_ id: Int64) throws { try execute("DELETE FROM queue WHERE id=?",[String(id)]) } + func moveQueue(_ id: Int64, venue: Int64, by offset: Int) throws { + var rows = try queue(venue:venue) + guard let index = rows.firstIndex(where: { $0.id == id }), rows.indices.contains(index+offset) else { return } + rows.swapAt(index,index+offset) + try execute("BEGIN IMMEDIATE") + do { for (i,row) in rows.enumerated() { try execute("UPDATE queue SET position=? WHERE id=?",[String(i),String(row.id)]) }; try execute("COMMIT") } + catch { try? execute("ROLLBACK"); throw error } + } + func addToPlaylist(_ playlist: Int64, song: Int64) throws { + try execute("INSERT OR IGNORE INTO playlist_songs(playlist,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM playlist_songs WHERE playlist=?))",[String(playlist),String(song),String(playlist)]) + } + func playlistSongIDs(_ playlist: Int64) throws -> [Int64] { + let s = try statement("SELECT song FROM playlist_songs WHERE playlist=? ORDER BY position",[String(playlist)]); defer { sqlite3_finalize(s) } + var ids = [Int64](); while sqlite3_step(s) == SQLITE_ROW { ids.append(sqlite3_column_int64(s,0)) }; return ids + } + func removeFromPlaylist(_ playlist: Int64, song: Int64) throws { try execute("DELETE FROM playlist_songs WHERE playlist=? AND song=?",[String(playlist),String(song)]) } + func assignSong(_ song: Int64, singer: Int64) throws { + try execute("INSERT OR IGNORE INTO singer_songs(singer,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM singer_songs WHERE singer=?))",[String(singer),String(song),String(singer)]) + } + func removeSingerSong(_ song: Int64, singer: Int64) throws { + try execute("DELETE FROM singer_songs WHERE singer=? AND song=?",[String(singer),String(song)]) + } + func singerSongIDs() throws -> [Int64:[Int64]] { + let s = try statement("SELECT singer,song FROM singer_songs ORDER BY singer,position,song",[]); defer { sqlite3_finalize(s) } + var result = [Int64:[Int64]]() + while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].append(sqlite3_column_int64(s,1)) } + return result + } + func defaultPreference(song: Int64) throws -> SongPreference { + let s = try statement("SELECT pitch,tempo FROM song_settings WHERE song=?",[String(song)]);defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_ROW else { return .init() } + return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:0) + } + func saveDefaultPreference(song: Int64, value: SongPreference) throws { + try execute("INSERT INTO song_settings(song,pitch,tempo) VALUES(?,?,?) ON CONFLICT(song) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo",[String(song),String(value.pitch),String(value.tempo)]) + } + func updateDuration(_ song: Int64, seconds: Double) throws { + guard seconds.isFinite && seconds >= 0 else { return } + try execute("UPDATE songs SET duration=? WHERE id=?",[String(seconds),String(song)]) + } + func setFavourite(song: Int64, singer: Int64, included: Bool) throws { + if included { try assignSong(song,singer:singer) } + try execute("UPDATE singer_songs SET favourite=? WHERE singer=? AND song=?",[included ? "1" : "0",String(singer),String(song)]) + } + func favouriteSongIDs() throws -> [Int64:Set] { + let s = try statement("SELECT singer,song FROM singer_songs WHERE favourite=1",[]);defer { sqlite3_finalize(s) } + var result = [Int64:Set]() + while sqlite3_step(s) == SQLITE_ROW { result[sqlite3_column_int64(s,0),default:[]].insert(sqlite3_column_int64(s,1)) };return result + } + func stores() throws -> [MusicStore] { + let s = try statement("SELECT id,name,url,search_template FROM music_stores ORDER BY name",[]);defer { sqlite3_finalize(s) } + var result = [MusicStore]() + while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1),url:text(s,2),searchTemplate:text(s,3))) };return result + } + func saveStore(_ store: MusicStore) throws { + let name = store.name.trimmingCharacters(in:.whitespacesAndNewlines),url = store.url.trimmingCharacters(in:.whitespacesAndNewlines),search = store.searchTemplate.trimmingCharacters(in:.whitespacesAndNewlines) + guard !name.isEmpty else { throw LibraryError.message("Enter a store name") } + let cleaned = MusicStore(id:store.id,name:name,url:url,searchTemplate:search) + _ = try cleaned.destination() + if !search.isEmpty { + guard search.contains("{query}") else { throw LibraryError.message("Search URL must contain {query}") } + _ = try cleaned.destination(query:"test") + } + if store.id == 0 { try execute("INSERT INTO music_stores(name,url,search_template) VALUES(?,?,?)",[name,url,search]) } + else { try execute("UPDATE music_stores SET name=?,url=?,search_template=? WHERE id=?",[name,url,search,String(store.id)]) } + } + func deleteStore(_ id: Int64) throws { try execute("DELETE FROM music_stores WHERE id=?",[String(id)]) } + func updateMetadata(song: Int64,title: String,artist: String,album: String,label: String,rating: Int) throws { + let title = title.trimmingCharacters(in:.whitespacesAndNewlines) + guard !title.isEmpty else { throw LibraryError.message("Song title cannot be empty") } + try execute("UPDATE songs SET title=?,artist=?,album=?,label=?,rating=?,metadata_edited=1 WHERE id=?",[title,artist.trimmingCharacters(in:.whitespacesAndNewlines),album.trimmingCharacters(in:.whitespacesAndNewlines),label.trimmingCharacters(in:.whitespacesAndNewlines),String(rating),String(song)]) + guard sqlite3_changes(db) == 1 else { throw LibraryError.message("Song no longer exists") } + } +} diff --git a/backups/before-tonight-fix-20261103-092811/KJamsCloneTests/KJamsCloneTests.swift b/backups/before-tonight-fix-20261103-092811/KJamsCloneTests/KJamsCloneTests.swift new file mode 100644 index 0000000..d39aa54 --- /dev/null +++ b/backups/before-tonight-fix-20261103-092811/KJamsCloneTests/KJamsCloneTests.swift @@ -0,0 +1,455 @@ +import XCTest +import AVFoundation +@testable import KJamsClone + +final class KJamsCloneTests: XCTestCase { + func packet(_ instruction: UInt8, _ data: [UInt8]) -> [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] } + func testPresetTileXORAndInvalidCoordinates() { + var d = CDGDecoder() + d.apply(packet(1,[3,0])); XCTAssertEqual(d.pixels[0],3) + d.apply(packet(6,[0,5,1,1] + Array(repeating:63,count:12))) + XCTAssertEqual(d.pixels[12*300+6],5) + d.apply(packet(38,[0,5,1,1] + Array(repeating:63,count:12))) + XCTAssertEqual(d.pixels[12*300+6],0) + let old = d.pixels; d.apply(packet(6,[1,2,31,63])); XCTAssertEqual(d.pixels,old) + } + func testSeekAndTruncatedPacket() { + var d = CDGDecoder() + let data = Data(packet(1,[2,0]) + packet(1,[7,0]) + [9]) + d.render(data,at:1); XCTAssertEqual(d.pixels[0],7); XCTAssertEqual(d.packetIndex,2) + d.render(data,at:1.0/300); XCTAssertEqual(d.pixels[0],2) + d.render(data,at:0); XCTAssertEqual(d.pixels[0],0) + } + func testPaletteBorderScrollAndImage() { + var d = CDGDecoder() + d.apply(packet(30,[63,63])); XCTAssertEqual(d.palette[0],0xFFFFFFFF) + d.apply(packet(2,[4])); XCTAssertEqual(d.pixels[0],4); XCTAssertEqual(d.pixels[12*300+6],0) + d.apply(packet(20,[3,16,0])); XCTAssertEqual(d.pixels[100*300],3) + d.apply(packet(24,[0,32,0])); XCTAssertEqual(d.pixels[100*300+294],3) + XCTAssertEqual(d.image()?.width,288) + } + func testSQLitePersistenceDedupRatingsAndHistory() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let url = folder.appendingPathComponent("test.sqlite") + do { + let db = try Library(url:url) + try db.add(title:"Singer's Song",audio:"/test.mp3",cdg:nil) + try db.add(title:"Duplicate",audio:"/test.mp3",cdg:"/test.cdg") + let song = try XCTUnwrap(db.songs().first) + try db.rate(song.id,5); XCTAssertThrowsError(try db.rate(song.id,6)) + try db.addEntity("singers",name:"O'Neil"); try db.addEntity("venues",name:"Hall") + try db.record(song.id,singer:db.entities("singers").first?.id,venue:db.entities("venues").first?.id) + } + let reopened = try Library(url:url); let songs = try reopened.songs() + XCTAssertEqual(songs.count,1); XCTAssertEqual(songs[0].rating,5); XCTAssertEqual(songs[0].plays,1); XCTAssertEqual(songs[0].cdg,"/test.cdg") + XCTAssertEqual(try reopened.entities("singers").first?.name,"O'Neil") + } + func testPairedImportAndContentDedup() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let audio = root.appendingPathComponent("Test.mp3"), cdg = root.appendingPathComponent("Test.CDG") + try Data([1,2,3]).write(to:audio); try Data(packet(1,[2,0])).write(to:cdg) + let db = try Library(url:root.appendingPathComponent("test.sqlite")) + let importer = Importer(media:root.appendingPathComponent("Media")) + try importer.run([audio],library:db); try importer.run([audio],library:db) + XCTAssertEqual(try db.songs().count,1) + XCTAssertNotNil(try db.songs().first?.cdg) + } + + func testZippedMP3GImportAndUnsafeArchiveRejection() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let db = try Library(url:folder.appendingPathComponent("db.sqlite")) + let importer = Importer(media:folder.appendingPathComponent("Media")) + try importer.run([fixtures.appendingPathComponent("Demo.zip")],library:db) + let song = try XCTUnwrap(db.songs().first) + XCTAssertEqual(song.title,"Demo"); XCTAssertNotNil(song.cdg) + XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio)) + XCTAssertThrowsError(try importer.run([fixtures.appendingPathComponent("Unsafe.zip")],library:db)) + } + @MainActor func testMP3PlaybackPauseAndSeek() throws { + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let player = Player() + defer { player.stop() } + try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0)) + XCTAssertGreaterThan(player.duration,3.9); XCTAssertTrue(player.playing) + player.toggle(); XCTAssertFalse(player.playing) + player.seek(2); XCTAssertEqual(player.time,2,accuracy:0.05); XCTAssertNotNil(player.image) + player.seek(0.1); XCTAssertEqual(player.time,0.1,accuracy:0.05) + } + @MainActor func testCreateSingerAndVenueThroughAppModel() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let model = AppModel(storageURL: root) + XCTAssertNil(model.error) + let singer = try model.createEntity(.singer, name: " Alex ") + let venue = try model.createEntity(.venue, name: "Town Hall") + XCTAssertEqual(model.singers.first?.name, "Alex") + XCTAssertEqual(model.venues.first?.name, "Town Hall") + XCTAssertEqual(try model.createEntity(.singer, name: "Alex"), singer) + XCTAssertEqual(model.singers.count, 1) + XCTAssertThrowsError(try model.createEntity(.venue, name: " ")) + let reopened = AppModel(storageURL: root) + XCTAssertEqual(reopened.singers.first?.id, singer) + XCTAssertEqual(reopened.venues.first?.id, venue) + } + + @MainActor func testVenuesShareSingersAndRememberSelection() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root) + let hall = try model.createEntity(.venue,name:"Hall") + let alex = try model.createEntity(.singer,name:"Alex") + let club = try model.createEntity(.venue,name:"Club") + XCTAssertTrue(model.venueSingers.isEmpty) + model.membership(singer:alex,included:true) + XCTAssertEqual(model.venueSingers.map(\.id),[alex]) + model.chooseVenue(hall); XCTAssertEqual(model.venueSingers.map(\.id),[alex]) + model.chooseVenue(club); model.membership(singer:alex,included:false) + XCTAssertTrue(model.venueSingers.isEmpty) + let reopened = AppModel(storageURL:root) + XCTAssertEqual(reopened.currentVenue,club); XCTAssertTrue(reopened.venueSingers.isEmpty) + reopened.chooseVenue(hall); XCTAssertEqual(reopened.venueSingers.map(\.id),[alex]) + XCTAssertEqual(reopened.singers.count,1) + } + func testMigrationAndMembershipRemovalDoesNotReappear() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let url = root.appendingPathComponent("db.sqlite") + var singer: Int64 = 0, venue: Int64 = 0 + do { + let db = try Library(url:url) + try db.add(title:"Song",audio:"/a.mp3",cdg:nil) + try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall") + singer = try XCTUnwrap(db.entities("singers").first?.id); venue = try XCTUnwrap(db.entities("venues").first?.id) + try db.record(XCTUnwrap(db.songs().first?.id),singer:singer,venue:venue) + try db.execute("PRAGMA user_version=1") + } + do { + let migrated = try Library(url:url) + XCTAssertEqual(try migrated.members(of:venue).map(\.id),[singer]) + try migrated.setMembership(singer:singer,venue:venue,included:false) + } + let reopened = try Library(url:url) + XCTAssertTrue(try reopened.members(of:venue).isEmpty) + XCTAssertEqual(try reopened.history(venue:venue).count,1) + } + func testQueuePlaylistsPreferencesAndDeletion() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let db = try Library(url:root.appendingPathComponent("db.sqlite")) + try db.add(title:"First",audio:"/1.mp3",cdg:nil) + try db.add(title:"Second",audio:"/2.mp3",cdg:nil) + let songs = try db.songs() + try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall"); try db.addEntity("venues",name:"Club") + let singer = try XCTUnwrap(db.entities("singers").first?.id) + let hall = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Hall" })?.id) + let club = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Club" })?.id) + XCTAssertThrowsError(try db.enqueue(song:songs[0].id,singer:singer,venue:hall)) + try db.setMembership(singer:singer,venue:hall,included:true) + try db.enqueue(song:songs[0].id,singer:singer,venue:hall); try db.enqueue(song:songs[1].id,singer:singer,venue:hall) + XCTAssertTrue(try db.queue(venue:club).isEmpty) + let second = try XCTUnwrap(db.queue(venue:hall).last) + try db.moveQueue(second.id,venue:hall,by:-1) + XCTAssertEqual(try db.queue(venue:hall).first?.songID,songs[1].id) + let preference = SongPreference(pitch:3,tempo:0.85,rating:4) + try db.savePreference(song:songs[0].id,singer:singer,value:preference) + XCTAssertEqual(try db.preference(song:songs[0].id,singer:singer),preference) + XCTAssertThrowsError(try db.savePreference(song:songs[0].id,singer:singer,value:.init(pitch:99))) + try db.addEntity("playlists",name:"Party") + let playlist = try XCTUnwrap(db.entities("playlists").first?.id) + try db.addToPlaylist(playlist,song:songs[0].id); try db.addToPlaylist(playlist,song:songs[0].id) + XCTAssertEqual(try db.playlistSongIDs(playlist),[songs[0].id]) + try db.record(songs[0].id,singer:singer,venue:hall) + try db.rename("singers",id:singer,name:"Alex Jones") + XCTAssertEqual(try db.history(venue:hall).first?.singer,"Alex Jones") + try db.deleteEntity("singers",id:singer) + XCTAssertTrue(try db.queue(venue:hall).isEmpty) + XCTAssertEqual(try db.history(venue:hall).count,1) + try db.selectVenue(hall); try db.deleteEntity("venues",id:hall) + XCTAssertNil(try db.currentVenue()); XCTAssertEqual(try db.history(venue:nil).count,1) + } + @MainActor func testTempoSourceClockPauseAndResume() async throws { + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let player = Player(); defer { player.stop() } + let song = Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0) + try player.load(song,preference:.init(pitch:2,tempo:1.5,rating:3)) + XCTAssertEqual(player.pitch,2); XCTAssertEqual(player.tempo,1.5) + try await Task.sleep(nanoseconds:500_000_000) + XCTAssertGreaterThan(player.time,0.45); XCTAssertLessThan(player.time,1.3) + player.toggle(); let paused = player.time + try await Task.sleep(nanoseconds:100_000_000) + XCTAssertEqual(player.time,paused,accuracy:0.025) + player.seek(1.5); player.toggle() + try await Task.sleep(nanoseconds:200_000_000) + XCTAssertGreaterThan(player.time,1.6); XCTAssertNotNil(player.image) + } + func testNewTestMediaMetadataAndCDGPairImport() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let db = try Library(url:root.appendingPathComponent("db.sqlite")) + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let importer = Importer(media:root.appendingPathComponent("Media")) + try importer.run([fixtures.appendingPathComponent("TestTrack.cdg")],library:db) + let song = try XCTUnwrap(db.songs().first) + XCTAssertEqual(song.title,"MP3 Plus G Test") + XCTAssertEqual(song.artist,"KJamsClone Test Generator") + XCTAssertNotNil(song.cdg) + let bytes = try Data(contentsOf:fixtures.appendingPathComponent("TestTrack.cdg")) + XCTAssertEqual(bytes.count,20*300*24) + var decoder = CDGDecoder(); decoder.render(bytes,at:2) + XCTAssertTrue(decoder.pixels.contains(1)); XCTAssertTrue(decoder.pixels.contains(2)) + } + @MainActor func testAutomaticVenueQueueAdvancesAndRecordsSinger() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root); defer { model.player.stop() } + _ = try model.createEntity(.venue,name:"Test Hall") + _ = try model.createEntity(.singer,name:"Alex") + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + model.importFiles([fixtures.appendingPathComponent("Demo.zip")]) + let song = try XCTUnwrap(model.songs.first) + model.enqueue(song.id); model.enqueue(song.id); model.playQueueNext() + XCTAssertEqual(model.queue.count,1); XCTAssertEqual(model.history.count,1) + model.player.seek(model.player.duration-0.1) + for _ in 0..<25 { + if model.queue.isEmpty { break } + try await Task.sleep(nanoseconds:100_000_000) + } + XCTAssertNil(model.error); XCTAssertTrue(model.queue.isEmpty) + XCTAssertEqual(model.history.count,2); XCTAssertEqual(model.history.first?.singer,"Alex") + } + func testWaveformRepresentsSilenceAndSignalInTimeOrder() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let url = folder.appendingPathComponent("signal.wav") + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:8000,channels:1)) + do { + let file = try AVAudioFile(forWriting:url,settings:format.settings) + let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:4096)); buffer.frameLength = 4096 + let samples = try XCTUnwrap(buffer.floatChannelData?[0]) + for i in 0..<4096 { samples[i] = i < 2048 ? 0 : Float(sin(Double(i)*0.2)*0.5) } + try file.write(from:buffer) + } + let envelope = try AudioWaveform.read(url:url,bins:32) + XCTAssertEqual(envelope.count,32) + XCTAssertTrue(envelope.prefix(16).allSatisfy { $0 == 0 }) + XCTAssertTrue(envelope.suffix(16).allSatisfy { $0 > 0.8 && $0 <= 1 }) + XCTAssertThrowsError(try AudioWaveform.read(url:url,cancelled:{true})) + } + @MainActor func testWaveformAnalysisAndStopClearsTrackData() async throws { + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let player = Player(); defer { player.stop() } + try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:nil,rating:0,plays:0)) + player.toggle() + for _ in 0..<50 { + if !player.waveformLoading { break } + try await Task.sleep(nanoseconds:20_000_000) + } + XCTAssertEqual(player.waveform.count,4096) + XCTAssertFalse(player.waveformUnavailable) + player.stop() + XCTAssertTrue(player.waveform.isEmpty) + XCTAssertFalse(player.waveformLoading) + try await Task.sleep(nanoseconds:50_000_000) + XCTAssertTrue(player.waveform.isEmpty) + } + + @MainActor func testConnectedSingerCreationAndSharedRepertoire() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root) + let hall = try model.createEntity(.venue,name:"Hall") + try model.library?.add(title:"Song",audio:"/sample.mp3",cdg:nil);try model.refresh() + let song = try XCTUnwrap(model.songs.first?.id) + let alex = try model.createSinger(name:" Alex ",venue:hall,songs:[song]) + XCTAssertEqual(model.selectedSinger,alex);XCTAssertEqual(model.currentVenue,hall) + XCTAssertEqual(model.venueSingers.map(\.id),[alex]);XCTAssertEqual(model.songs(for:alex).map(\.id),[song]) + XCTAssertThrowsError(try model.createSinger(name:"Broken",venue:hall,songs:[999999])) + XCTAssertFalse(try XCTUnwrap(model.library).entities("singers").contains { $0.name == "Broken" }) + let club = try model.createEntity(.venue,name:"Club");model.membership(singer:alex,included:true) + XCTAssertEqual(model.songs(for:alex).map(\.id),[song]) + model.assign(song,to:alex);XCTAssertEqual(model.songs(for:alex).count,1) + let reopened = AppModel(storageURL:root) + XCTAssertEqual(reopened.currentVenue,club);XCTAssertEqual(reopened.songs(for:alex).map(\.id),[song]) + reopened.assign(song,to:alex,included:false) + XCTAssertTrue(AppModel(storageURL:root).songs(for:alex).isEmpty) + } + func testQueueRemembersSingerSongWithoutDuplicatingAssignment() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let db = try Library(url:root.appendingPathComponent("db.sqlite")) + try db.addEntity("singers",name:"Alex");try db.addEntity("venues",name:"Hall") + try db.add(title:"Song",audio:"/a.mp3",cdg:nil) + let singer = try XCTUnwrap(db.entities("singers").first?.id),venue = try XCTUnwrap(db.entities("venues").first?.id),song = try XCTUnwrap(db.songs().first?.id) + try db.setMembership(singer:singer,venue:venue,included:true) + try db.enqueue(song:song,singer:singer,venue:venue);try db.enqueue(song:song,singer:singer,venue:venue) + XCTAssertEqual(try db.singerSongIDs()[singer],[song]);XCTAssertEqual(try db.queue(venue:venue).count,2) + try db.removeSingerSong(song,singer:singer) + XCTAssertNil(try db.singerSongIDs()[singer]);XCTAssertEqual(try db.songs().count,1) + } + func testFrequencyBandsDistinguishBassAndTreble() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let url = root.appendingPathComponent("bands.wav") + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:16000,channels:1)) + do { + let file = try AVAudioFile(forWriting:url,settings:format.settings) + let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:8192));buffer.frameLength = 8192 + for i in 0..<8192 { buffer.floatChannelData![0][i] = Float(sin(Double(i)*2*Double.pi*(i < 4096 ? 80 : 6000)/16000)*0.2) } + try file.write(from:buffer) + } + let analysis = try AudioWaveform.analyse(url:url,bins:32) + XCTAssertGreaterThan(analysis.bands[8].low,analysis.bands[8].high*3) + XCTAssertGreaterThan(analysis.bands[24].high,analysis.bands[24].low*3) + } + private func toneRMS(frequency: Double, low: Double = 0, filter: Double = 0, enabled: Bool = true) throws -> Double { + let engine = AVAudioEngine(),node = AVAudioPlayerNode(),eq = AVAudioUnitEQ(numberOfBands:4) + let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:1)) + engine.attach(node);engine.attach(eq);engine.connect(node,to:eq,format:format);engine.connect(eq,to:engine.mainMixerNode,format:format) + AudioTone.configure(eq,sampleRate:44100,low:low,mid:0,high:0,filter:filter,enabled:enabled) + try engine.enableManualRenderingMode(.offline,format:format,maximumFrameCount:1024) + let input = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:32768));input.frameLength = 32768 + for i in 0..<32768 { input.floatChannelData![0][i] = Float(sin(Double(i)*2*Double.pi*frequency/44100)*0.1) } + node.scheduleBuffer(input);try engine.start();node.play();defer { node.stop();engine.stop() } + let output = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:1024)) + var energy: Double = 0,count = 0 + for block in 0..<12 { + let status = try engine.renderOffline(1024,to:output) + XCTAssertEqual(status,.success) + if block >= 2 { for i in 0.. String { metadata.first { $0.commonKey == key }?.stringValue ?? "" } + let taggedTitle = tag(.commonKeyTitle) + try library.add(title: taggedTitle.isEmpty ? audio.deletingPathExtension().lastPathComponent : taggedTitle, + audio: dest.path, cdg: graphics, artist: tag(.commonKeyArtist), album: tag(.commonKeyAlbumName), label: tag(.commonKeyPublisher)) + } + } + private func process(_ arguments: [String]) throws -> String { + let task = Process(); task.executableURL = URL(fileURLWithPath: "/usr/bin/unzip"); task.arguments = arguments + let out = Pipe(); task.standardOutput = out + let err = FileHandle.nullDevice; task.standardError = err + try task.run() + let bytes = out.fileHandleForReading.readDataToEndOfFile(); task.waitUntilExit() + guard task.terminationStatus == 0 else { throw LibraryError.message("Cannot read ZIP archive") } + return String(decoding: bytes, as: UTF8.self) + } +} diff --git a/backups/before-venues-20261102-231352/KJamsClone/KJamsCloneApp.swift b/backups/before-venues-20261102-231352/KJamsClone/KJamsCloneApp.swift new file mode 100644 index 0000000..e448694 --- /dev/null +++ b/backups/before-venues-20261102-231352/KJamsClone/KJamsCloneApp.swift @@ -0,0 +1,379 @@ +import SwiftUI +import UniformTypeIdentifiers + +enum EntityKind: String, Identifiable { + case singer, venue + var id: String { rawValue } + var title: String { self == .singer ? "Singer" : "Venue" } + var table: String { self == .singer ? "singers" : "venues" } +} + +@MainActor final class AppModel: ObservableObject { + @Published var songs = [Song]() + @Published var singers = [PersonPlace]() + @Published var venues = [PersonPlace]() + @Published var error: String? + @Published var creating: EntityKind? + let player = Player() + private(set) var library: Library? + let root: URL + init(storageURL: URL? = nil) { + root = storageURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("KJamsClone") + perform { + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + library = try Library(url: root.appendingPathComponent("Library.sqlite")); try refresh() + } + } + func perform(_ work: () throws -> Void) { do { try work() } catch { self.error = error.localizedDescription } } + func refresh() throws { guard let library else { return }; songs = try library.songs(); singers = try library.entities("singers"); venues = try library.entities("venues") } + @discardableResult func createEntity(_ kind: EntityKind, name: String) throws -> Int64 { + guard let library else { throw LibraryError.message("Library unavailable") } + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + try library.addEntity(kind.table, name: trimmed) + try refresh() + let entities = kind == .singer ? singers : venues + guard let entity = entities.first(where: { $0.name == trimmed }) else { + throw LibraryError.message("The new \(kind.title.lowercased()) could not be loaded") + } + return entity.id + } + func importFiles(_ urls: [URL]) { perform { guard let library else { throw LibraryError.message("Library unavailable") }; try Importer(media: root.appendingPathComponent("Media")).run(urls, library: library); try refresh() } } +} +@main struct KJamsCloneApp: App { + @StateObject private var model = AppModel() + var body: some Scene { + Window("KJamsClone", id: "main") { + LibraryView(model: model).frame(minWidth: 1060, minHeight: 680).preferredColorScheme(.dark) + }.defaultSize(width: 1260, height: 840) + .commands { + CommandGroup(replacing: .newItem) { + Button("New Singer…") { model.creating = .singer }.keyboardShortcut("n", modifiers: [.command, .shift]) + Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option]) + } + } + Window("Video", id: "lyrics") { + LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark) + }.defaultSize(width: 600, height: 400) + } +} +private enum Classic { + static let frame = Color(red: 0.15, green: 0.15, blue: 0.15) + static let navy = Color(red: 0.008, green: 0.065, blue: 0.16) + static let blue = Color(red: 0.015, green: 0.17, blue: 0.42) + static let header = Color(white: 0.115) + static let line = Color(white: 0.42) +} +struct LyricsView: View { + @ObservedObject var player: Player + var body: some View { + ZStack { + Color.black + if let image = player.image { + Image(decorative: image, scale: 1).interpolation(.none).resizable().aspectRatio(1.5, contentMode: .fit) + } + } + } +} +private struct SilverSquareStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label.foregroundStyle(Color(white: 0.25)).padding(.horizontal, 6).padding(.vertical, 3) + .background(LinearGradient(colors: [Color(white: 0.95), Color(white: 0.64)], startPoint: .top, endPoint: .bottom), in: RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Color(white: 0.45), lineWidth: 1.5)) + .overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(.white.opacity(0.7)).padding(3)) + .opacity(configuration.isPressed ? 0.7 : 1) + } +} +private struct SilverButton: View { + let symbol: String + let label: String + var size: CGFloat = 40 + var active = false + var enabled = true + let action: () -> Void + var body: some View { + Button(action: action) { + Image(systemName: symbol).font(.system(size: size * 0.41, weight: .semibold)) + .foregroundStyle(active ? Color.blue : Color(white: 0.24)) + .frame(width: size, height: size) + .background(LinearGradient(colors: [.white, Color(white: 0.85), Color(white: 0.63)], startPoint: .top, endPoint: .bottom), in: Circle()) + .overlay(Circle().strokeBorder(Color(white: 0.45), lineWidth: 1.5)) + .overlay(Circle().strokeBorder(.white.opacity(0.65), lineWidth: 1).padding(3)) + .shadow(color: .black.opacity(0.7), radius: 1, y: 1) + }.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.72) + .help(label).accessibilityLabel(label) + } +} +struct LibraryView: View { + @ObservedObject var model: AppModel + @State private var selected: Int64? + @State private var singer: Int64? + @State private var venue: Int64? + @State private var search = "" + @State private var name = "" + @State private var importing = false + @State private var source = "Library" + @Environment(\.openWindow) private var openWindow + private var visibleSongs: [Song] { model.songs.filter { search.isEmpty || $0.title.localizedCaseInsensitiveContains(search) } } + private var selectedSong: Song? { model.songs.first { $0.id == selected } } + var body: some View { + VStack(spacing: 0) { + TransportView(player: model.player, search: $search, hasSelection: selectedSong != nil, + play: playSelected, next: playNext, display: { openWindow(id: "lyrics") }) + .padding(.horizontal, 18).padding(.top, 18).padding(.bottom, 22) + HSplitView { + sidebar.frame(minWidth: 190, idealWidth: 220, maxWidth: 240) + VStack(spacing: 12) { + if source == "Library" { songTable } else { directory } + detailPanel.frame(height: 84) + }.padding(.leading, 12).frame(minWidth: 780) + }.padding(.horizontal, 18) + footer.padding(.horizontal, 18).padding(.vertical, 12) + } + .background(Classic.frame) + .sheet(item: $model.creating) { kind in + AddEntitySheet(model: model, kind: kind) { id in + if kind == .singer { singer = id } else { venue = id } + } + } + .fileImporter(isPresented: $importing, allowedContentTypes: [.audio, .zip, UTType(filenameExtension: "cdg") ?? .data], allowsMultipleSelection: true) { result in + switch result { + case .success(let urls): model.importFiles(urls) + case .failure(let error): model.error = error.localizedDescription + } + } + .alert("KJamsClone", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { + Button("OK") { model.error = nil } + } message: { Text(model.error ?? "") } + } + private var sidebar: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Source").font(.system(size: 15, weight: .semibold)).padding(.horizontal, 10).frame(height: 31) + .frame(maxWidth: .infinity, alignment: .leading).background(Classic.header) + ForEach([("Library", "music.note.house.fill", Color.yellow), ("Singers", "person.2.fill", Color.green), ("Venues", "building.2.fill", Color.cyan)], id: \.0) { item in + Button { source = item.0 } label: { + HStack(spacing: 10) { + Image(systemName: item.1).foregroundStyle(item.2).frame(width: 22) + Text(item.0).font(.system(size: 16, weight: .medium)) + Spacer() + Text("\(item.0 == "Library" ? model.songs.count : item.0 == "Singers" ? model.singers.count : model.venues.count)") + .font(.system(size: 11)).foregroundStyle(.white.opacity(0.6)) + }.padding(.horizontal, 12).frame(height: 34).contentShape(Rectangle()) + .background(source == item.0 ? Color(red: 0, green: 0.36, blue: 1) : .clear) + }.buttonStyle(.plain) + } + Spacer() + }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) + } + private var songTable: some View { + GeometryReader { geometry in + ScrollView(.horizontal) { + VStack(spacing: 0) { + tableHeader(width: max(geometry.size.width, 790)) + ScrollView(.vertical) { + LazyVStack(spacing: 0) { + ForEach(visibleSongs) { song in songRow(song, width: max(geometry.size.width, 790)) } + }.frame(maxWidth: .infinity, alignment: .topLeading) + } + }.frame(width: max(geometry.size.width, 790), height: geometry.size.height) + } + }.background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) + } + private func widths(_ total: CGFloat) -> [CGFloat] { + [42, max(200, total - 598), 100, 100, 76, 86, 72, 122] + } + private func tableHeader(width: CGFloat) -> some View { + let sizes = widths(width) + return HStack(spacing: 0) { + ForEach(Array(["PLI", "Song Name", "Artist", "Album", "Label", "Key Adjust", "Tempo", "Rating"].enumerated()), id: \.offset) { index, title in + Text(title).font(.system(size: 13, weight: .medium)).lineLimit(1) + .padding(.leading, 7).frame(width: sizes[index], height: 30, alignment: .leading) + .overlay(alignment: .trailing) { Rectangle().fill(Classic.line).frame(width: 1).padding(.vertical, 4) } + } + }.foregroundStyle(.white).background(Classic.header) + .overlay(alignment: .bottom) { Rectangle().fill(Color(white: 0.65)).frame(height: 1) } + } + private func songRow(_ song: Song, width: CGFloat) -> some View { + let sizes = widths(width) + return HStack(spacing: 0) { + Image(systemName: song.cdg == nil ? "music.note" : "music.note.tv") + .foregroundStyle(.white.opacity(0.75)).frame(width: sizes[0]) + Text(song.title).lineLimit(1).padding(.leading, 7).frame(width: sizes[1], alignment: .leading) + ForEach(2..<7) { index in + Text(index == 4 ? (song.cdg == nil ? "Audio" : "MP3+G") : "—") + .foregroundStyle(.white.opacity(0.5)).padding(.leading, 7).frame(width: sizes[index], alignment: .leading) + } + Menu { + ForEach(0..<6) { value in + Button(value == 0 ? "Unrated" : String(repeating: "★", count: value)) { + model.perform { try model.library?.rate(song.id, value); try model.refresh() } + } + } + } label: { + Text(song.rating == 0 ? "☆☆☆☆☆" : String(repeating: "★", count: song.rating) + String(repeating: "☆", count: 5 - song.rating)) + .font(.system(size: 13)).foregroundStyle(song.rating == 0 ? Color.gray : Color.yellow) + }.menuStyle(.borderlessButton).frame(width: sizes[7] - 14).padding(.horizontal, 7).help("Set song rating") + }.font(.system(size: 13)).foregroundStyle(.white).frame(height: 30) + .background(selected == song.id ? Color(red: 0, green: 0.28, blue: 0.72) : .clear) + .contentShape(Rectangle()).onTapGesture(count: 2) { selected = song.id; playSelected() } + .onTapGesture { selected = song.id } + } + private var directory: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text(source).font(.system(size: 14, weight: .semibold)) + Spacer() + Button(source == "Singers" ? "New Singer…" : "New Venue…") { model.creating = source == "Singers" ? .singer : .venue } + }.padding(.horizontal, 12).frame(height: 36).background(Classic.header) + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(source == "Singers" ? model.singers : model.venues) { entity in + Button { + if source == "Singers" { singer = entity.id } else { venue = entity.id } + } label: { + HStack { Image(systemName: source == "Singers" ? "person.fill" : "building.2.fill"); Text(entity.name); Spacer() + if entity.id == (source == "Singers" ? singer : venue) { Image(systemName: "checkmark") } + }.padding(12).contentShape(Rectangle()) + }.buttonStyle(.plain) + } + } + } + HStack { + TextField(source == "Singers" ? "Singer name" : "Venue name", text: $name).textFieldStyle(.roundedBorder).onSubmit(addEntity) + Button("Add \(source == "Singers" ? "Singer" : "Venue")", action: addEntity).disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + }.padding(12) + }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) + } + private var detailPanel: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 16) { + Picker("Singer", selection: $singer) { Text("None").tag(nil as Int64?); ForEach(model.singers) { Text($0.name).tag(Optional($0.id)) } } + Button { model.creating = .singer } label: { Image(systemName: "plus.circle.fill") } + .buttonStyle(.plain).help("Add singer").accessibilityLabel("Add singer") + Picker("Venue", selection: $venue) { Text("None").tag(nil as Int64?); ForEach(model.venues) { Text($0.name).tag(Optional($0.id)) } } + Button { model.creating = .venue } label: { Image(systemName: "plus.circle.fill") } + .buttonStyle(.plain).help("Add venue").accessibilityLabel("Add venue") + Text(selectedSong.map { "\($0.plays) plays" } ?? "No song selected").foregroundStyle(.white.opacity(0.55)).font(.system(size: 11)) + } + SeekBar(player: model.player) + }.padding(12).frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) + } + private var footer: some View { + HStack { + Button { importing = true } label: { Image(systemName: "plus").font(.system(size: 20, weight: .bold)).frame(width: 32, height: 24) } + .help("Import audio, MP3+G or ZIP").accessibilityLabel("Import songs") + RepeatButton(player: model.player).padding(.leading, 12) + Spacer() + Text("\(visibleSongs.count) \(visibleSongs.count == 1 ? "Song" : "Songs")").font(.system(size: 14, weight: .medium)) + Spacer() + Button { openWindow(id: "lyrics") } label: { Image(systemName: "rectangle.on.rectangle").frame(width: 32, height: 24) } + .help("Open video window").accessibilityLabel("Open video window") + }.buttonStyle(SilverSquareStyle()) + } + private func playSelected() { + guard let song = selectedSong else { return } + model.perform { try model.player.load(song); try model.library?.record(song.id, singer: singer, venue: venue); try model.refresh() } + } + private func playNext() { + guard !visibleSongs.isEmpty else { return } + let index = visibleSongs.firstIndex { $0.id == selected } ?? -1 + selected = visibleSongs[(index + 1) % visibleSongs.count].id; playSelected() + } + private func addEntity() { + model.perform { + let kind: EntityKind = source == "Singers" ? .singer : .venue + let id = try model.createEntity(kind, name: name) + if kind == .singer { singer = id } else { venue = id } + name = "" + } + } +} +private struct AddEntitySheet: View { + @ObservedObject var model: AppModel + let kind: EntityKind + let saved: (Int64) -> Void + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var error: String? + @FocusState private var focused: Bool + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("New \(kind.title)").font(.title2.bold()) + TextField("\(kind.title) name", text: $name).textFieldStyle(.roundedBorder) + .focused($focused).onSubmit(save) + if let error { Text(error).foregroundStyle(.red).font(.callout) } + HStack { + Spacer() + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Button("Add \(kind.title)", action: save).keyboardShortcut(.defaultAction) + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + }.padding(24).frame(width: 360).onAppear { focused = true } + } + private func save() { + do { let id = try model.createEntity(kind, name: name); saved(id); dismiss() } + catch { self.error = error.localizedDescription } + } +} +private struct TransportView: View { + @ObservedObject var player: Player + @Binding var search: String + let hasSelection: Bool + let play: () -> Void + let next: () -> Void + let display: () -> Void + var body: some View { + HStack(alignment: .top, spacing: 28) { + VStack(spacing: 15) { + HStack(spacing: 7) { + SilverButton(symbol: "backward.fill", label: "Back 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time - 10) } + SilverButton(symbol: player.playing ? "pause.fill" : "play.fill", label: player.playing ? "Pause" : "Play", size: 49, enabled: hasSelection || player.duration > 0) { + if player.duration > 0 { player.toggle() } else { play() } + } + SilverButton(symbol: "stop", label: "Stop", size: 46, enabled: player.duration > 0) { player.stop() } + SilverButton(symbol: "forward.fill", label: "Forward 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time + 10) } + SilverButton(symbol: "forward.end.fill", label: "Play next song", size: 26, enabled: hasSelection, action: next) + } + HStack(spacing: 9) { + Image(systemName: "speaker.fill").font(.system(size: 11)) + Slider(value: $player.volume, in: 0...1).controlSize(.small).tint(.gray).accessibilityLabel("Volume") + Image(systemName: "speaker.wave.3.fill").font(.system(size: 12)) + }.frame(width: 180) + }.frame(width: 220) + VStack(spacing: 0) { + Text(player.duration == 0 ? "Ready!" : player.title).font(.system(size: 15, weight: .medium)).lineLimit(1).padding(.horizontal, 20) + if player.duration > 0 { Text(player.playing ? "Playing" : "Paused").font(.system(size: 10)).foregroundStyle(.white.opacity(0.65)).padding(.top, 4) } + }.frame(maxWidth: .infinity).frame(height: 64) + .background(LinearGradient(colors: [Color(red: 0, green: 0.10, blue: 0.30), Classic.blue], startPoint: .top, endPoint: .bottom), in: Capsule()) + .overlay(Capsule().strokeBorder(LinearGradient(colors: [.black, .white], startPoint: .top, endPoint: .bottom), lineWidth: 1.5)) + .padding(.top, 14) + HStack(spacing: 12) { + TextField("Search", text: $search).textFieldStyle(.plain).font(.system(size: 16)) + .padding(.horizontal, 8).frame(width: 145, height: 28).background(Color(white: 0.07), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).stroke(.white.opacity(0.1))) + SilverButton(symbol: "eye", label: "Open video window", size: 48, action: display) + }.padding(.top, 15) + }.foregroundStyle(.white).frame(height: 96) + } +} +private struct SeekBar: View { + @ObservedObject var player: Player + private func clock(_ seconds: Double) -> String { let n = Int(seconds); return String(format: "%d:%02d", n / 60, n % 60) } + var body: some View { + HStack(spacing: 10) { + Text(clock(player.time)) + Slider(value: Binding(get: { player.time }, set: { player.seek($0) }), in: 0...max(1, player.duration)) + .controlSize(.mini).tint(.gray).disabled(player.duration == 0).accessibilityLabel("Playback position") + Text(clock(player.duration)) + }.font(.system(size: 10, design: .monospaced)).foregroundStyle(.white.opacity(0.65)) + } +} +private struct RepeatButton: View { + @ObservedObject var player: Player + var body: some View { + Button { player.repeating.toggle() } label: { + Image(systemName: "repeat").font(.system(size: 18, weight: .semibold)) + .foregroundStyle(player.repeating ? Color.blue : Color(white: 0.25)).frame(width: 32, height: 24) + }.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off") + } +} diff --git a/backups/before-venues-20261102-231352/KJamsClone/Library.swift b/backups/before-venues-20261102-231352/KJamsClone/Library.swift new file mode 100644 index 0000000..5bc07da --- /dev/null +++ b/backups/before-venues-20261102-231352/KJamsClone/Library.swift @@ -0,0 +1,196 @@ +import Foundation +import SQLite3 + +struct Song: Identifiable, Hashable { + var id: Int64 + var title: String + var audio: String + var cdg: String? + var rating: Int + var plays: Int + var artist: String = "" + var album: String = "" + var label: String = "" +} +struct PersonPlace: Identifiable, Hashable { var id: Int64; var name: String } +struct SongPreference: Equatable { + var pitch: Double = 0 + var tempo: Double = 1 + var rating: Int = 0 +} +struct Performance: Identifiable { + var id: Int64 + var song: String + var singer: String + var venue: String + var date: String +} +struct QueueEntry: Identifiable { + var id: Int64 + var songID: Int64 + var singerID: Int64? + var title: String + var singer: String +} +enum LibraryError: LocalizedError { + case message(String) + var errorDescription: String? { if case .message(let s) = self { return s }; return nil } +} +final class Library { + private var db: OpaquePointer? + init(url: URL) throws { + guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw LibraryError.message("Cannot open library") } + try execute("PRAGMA foreign_keys=ON") + try execute("CREATE TABLE IF NOT EXISTS songs(id INTEGER PRIMARY KEY,title TEXT NOT NULL,audio TEXT UNIQUE NOT NULL,cdg TEXT,rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),plays INTEGER NOT NULL DEFAULT 0)") + try execute("CREATE TABLE IF NOT EXISTS singers(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS venues(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS history(id INTEGER PRIMARY KEY,song INTEGER REFERENCES songs(id),singer INTEGER REFERENCES singers(id),venue INTEGER REFERENCES venues(id),played TEXT DEFAULT CURRENT_TIMESTAMP)") + // Additive migration: preserve older libraries and infer existing memberships from history. + for column in ["artist", "album", "label"] { + if try !columns("songs").contains(column) { try execute("ALTER TABLE songs ADD COLUMN \(column) TEXT NOT NULL DEFAULT ''") } + } + try execute("CREATE TABLE IF NOT EXISTS venue_singers(venue INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,PRIMARY KEY(venue,singer))") + if try schemaVersion() < 3 { try execute("INSERT OR IGNORE INTO venue_singers SELECT DISTINCT venue,singer FROM history WHERE venue IS NOT NULL AND singer IS NOT NULL") } + try execute("CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT)") + try execute("CREATE TABLE IF NOT EXISTS preferences(song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER NOT NULL REFERENCES singers(id) ON DELETE CASCADE,pitch REAL NOT NULL DEFAULT 0 CHECK(pitch BETWEEN -12 AND 12),tempo REAL NOT NULL DEFAULT 1 CHECK(tempo BETWEEN 0.5 AND 1.5),rating INTEGER NOT NULL DEFAULT 0 CHECK(rating BETWEEN 0 AND 5),PRIMARY KEY(song,singer))") + try execute("CREATE TABLE IF NOT EXISTS queue(id INTEGER PRIMARY KEY,venue INTEGER REFERENCES venues(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,singer INTEGER REFERENCES singers(id) ON DELETE CASCADE,position INTEGER NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS playlists(id INTEGER PRIMARY KEY,name TEXT UNIQUE NOT NULL)") + try execute("CREATE TABLE IF NOT EXISTS playlist_songs(playlist INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,song INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,position INTEGER NOT NULL,PRIMARY KEY(playlist,song))") + try execute("PRAGMA user_version=3") + } + deinit { sqlite3_close(db) } + private func statement(_ sql: String, _ values: [String?]) throws -> OpaquePointer { + var s: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &s, nil) == SQLITE_OK, let s else { throw error() } + for (i,v) in values.enumerated() { + let code = v.map { sqlite3_bind_text(s, Int32(i + 1), $0, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) } ?? sqlite3_bind_null(s, Int32(i + 1)) + if code != SQLITE_OK { sqlite3_finalize(s); throw error() } + } + return s + } + private func error() -> LibraryError { .message(String(cString: sqlite3_errmsg(db))) } + func execute(_ sql: String, _ values: [String?] = []) throws { + let s = try statement(sql, values); defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_DONE else { throw error() } + } + func songs() throws -> [Song] { + let s = try statement("SELECT id,title,audio,cdg,rating,plays,artist,album,label FROM songs ORDER BY title COLLATE NOCASE", []); defer { sqlite3_finalize(s) } + var result = [Song]() + while true { + let code = sqlite3_step(s); if code == SQLITE_DONE { break }; guard code == SQLITE_ROW else { throw error() } + result.append(Song(id: sqlite3_column_int64(s,0), title: String(cString: sqlite3_column_text(s,1)), audio: String(cString: sqlite3_column_text(s,2)), cdg: sqlite3_column_text(s,3).map { String(cString:$0) }, rating: Int(sqlite3_column_int(s,4)), plays: Int(sqlite3_column_int(s,5)), artist: text(s,6), album: text(s,7), label: text(s,8))) + } + return result + } + func add(title: String, audio: String, cdg: String?, artist: String = "", album: String = "", label: String = "") throws { + try execute("INSERT INTO songs(title,audio,cdg,artist,album,label) VALUES(?,?,?,?,?,?) ON CONFLICT(audio) DO UPDATE SET cdg=COALESCE(excluded.cdg,songs.cdg),artist=CASE WHEN excluded.artist!='' THEN excluded.artist ELSE songs.artist END,album=CASE WHEN excluded.album!='' THEN excluded.album ELSE songs.album END,label=CASE WHEN excluded.label!='' THEN excluded.label ELSE songs.label END", [title,audio,cdg,artist,album,label]) + } + func rate(_ id: Int64, _ rating: Int) throws { try execute("UPDATE songs SET rating=? WHERE id=?", [String(rating),String(id)]) } + func entities(_ table: String) throws -> [PersonPlace] { + precondition(["singers","venues","playlists"].contains(table)) + let s = try statement("SELECT id,name FROM \(table) ORDER BY name", []); defer { sqlite3_finalize(s) } + var rows = [PersonPlace]() + while sqlite3_step(s) == SQLITE_ROW { rows.append(.init(id: sqlite3_column_int64(s,0), name: String(cString: sqlite3_column_text(s,1)))) } + return rows + } + func addEntity(_ table: String, name: String) throws { + precondition(["singers","venues","playlists"].contains(table)) + let name = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { throw LibraryError.message("Enter a name") } + try execute("INSERT OR IGNORE INTO \(table)(name) VALUES(?)", [name]) + } + func record(_ song: Int64, singer: Int64?, venue: Int64?) throws { + try execute("BEGIN IMMEDIATE") + do { + try execute("INSERT INTO history(song,singer,venue) VALUES(?,?,?)", [String(song),singer.map(String.init),venue.map(String.init)]) + try execute("UPDATE songs SET plays=plays+1 WHERE id=?", [String(song)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK"); throw error } + } + + private func text(_ s: OpaquePointer, _ index: Int32) -> String { sqlite3_column_text(s,index).map { String(cString:$0) } ?? "" } + private func schemaVersion() throws -> Int { + let s = try statement("PRAGMA user_version",[]); defer { sqlite3_finalize(s) } + return sqlite3_step(s) == SQLITE_ROW ? Int(sqlite3_column_int(s,0)) : 0 + } + private func columns(_ table: String) throws -> Set { + let s = try statement("PRAGMA table_info(\(table))", []); defer { sqlite3_finalize(s) } + var result = Set(); while sqlite3_step(s) == SQLITE_ROW { result.insert(text(s,1)) }; return result + } + func members(of venue: Int64) throws -> [PersonPlace] { + let s = try statement("SELECT singers.id,singers.name FROM singers JOIN venue_singers ON singers.id=venue_singers.singer WHERE venue=? ORDER BY name", [String(venue)]); defer { sqlite3_finalize(s) } + var result = [PersonPlace](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),name:text(s,1))) }; return result + } + func setMembership(singer: Int64, venue: Int64, included: Bool) throws { + try execute(included ? "INSERT OR IGNORE INTO venue_singers(venue,singer) VALUES(?,?)" : "DELETE FROM venue_singers WHERE venue=? AND singer=?", [String(venue),String(singer)]) + if !included { try execute("DELETE FROM queue WHERE venue=? AND singer=?",[String(venue),String(singer)]) } + } + func currentVenue() throws -> Int64? { + let s = try statement("SELECT venues.id FROM venues JOIN settings ON CAST(settings.value AS INTEGER)=venues.id WHERE settings.key='currentVenue'", []); defer { sqlite3_finalize(s) } + return sqlite3_step(s) == SQLITE_ROW ? sqlite3_column_int64(s,0) : nil + } + func selectVenue(_ id: Int64?) throws { + if let id { + guard try entities("venues").contains(where: { $0.id == id }) else { throw LibraryError.message("Venue no longer exists") } + } + try execute("INSERT INTO settings(key,value) VALUES('currentVenue',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",[id.map(String.init)]) + } + func rename(_ table: String, id: Int64, name: String) throws { + precondition(["singers","venues","playlists"].contains(table)) + let name = name.trimmingCharacters(in:.whitespacesAndNewlines) + guard !name.isEmpty else { throw LibraryError.message("Enter a name") } + try execute("UPDATE \(table) SET name=? WHERE id=?",[name,String(id)]) + } + func deleteEntity(_ table: String, id: Int64) throws { + precondition(["singers","venues","playlists"].contains(table)) + try execute("BEGIN IMMEDIATE") + do { + if table == "singers" { try execute("UPDATE history SET singer=NULL WHERE singer=?",[String(id)]) } + if table == "venues" { + try execute("UPDATE history SET venue=NULL WHERE venue=?",[String(id)]) + try execute("DELETE FROM settings WHERE key='currentVenue' AND value=?",[String(id)]) + } + try execute("DELETE FROM \(table) WHERE id=?",[String(id)]) + try execute("COMMIT") + } catch { try? execute("ROLLBACK"); throw error } + } + func preference(song: Int64, singer: Int64) throws -> SongPreference { + let s = try statement("SELECT pitch,tempo,rating FROM preferences WHERE song=? AND singer=?",[String(song),String(singer)]); defer { sqlite3_finalize(s) } + guard sqlite3_step(s) == SQLITE_ROW else { return .init() } + return .init(pitch:sqlite3_column_double(s,0),tempo:sqlite3_column_double(s,1),rating:Int(sqlite3_column_int(s,2))) + } + func savePreference(song: Int64, singer: Int64, value: SongPreference) throws { + try execute("INSERT INTO preferences(song,singer,pitch,tempo,rating) VALUES(?,?,?,?,?) ON CONFLICT(song,singer) DO UPDATE SET pitch=excluded.pitch,tempo=excluded.tempo,rating=excluded.rating",[String(song),String(singer),String(value.pitch),String(value.tempo),String(value.rating)]) + } + func history(venue: Int64?, singer: Int64? = nil) throws -> [Performance] { + let s = try statement("SELECT history.id,COALESCE(songs.title,'Deleted song'),COALESCE(singers.name,'—'),COALESCE(venues.name,'—'),played FROM history LEFT JOIN songs ON songs.id=history.song LEFT JOIN singers ON singers.id=history.singer LEFT JOIN venues ON venues.id=history.venue WHERE (? IS NULL OR history.venue=?) AND (? IS NULL OR history.singer=?) ORDER BY history.id DESC",[venue.map(String.init),venue.map(String.init),singer.map(String.init),singer.map(String.init)]); defer { sqlite3_finalize(s) } + var result = [Performance](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),song:text(s,1),singer:text(s,2),venue:text(s,3),date:text(s,4))) }; return result + } + func enqueue(song: Int64, singer: Int64?, venue: Int64?) throws { + guard let venue else { throw LibraryError.message("Select a current venue before adding to its queue") } + if let singer, try !members(of:venue).contains(where: { $0.id == singer }) { throw LibraryError.message("Add this singer to the current venue first") } + try execute("INSERT INTO queue(venue,song,singer,position) VALUES(?,?,?,(SELECT COALESCE(MAX(position),0)+1 FROM queue WHERE venue=?))",[String(venue),String(song),singer.map(String.init),String(venue)]) + } + func queue(venue: Int64?) throws -> [QueueEntry] { + guard let venue else { return [] } + let s = try statement("SELECT queue.id,queue.song,queue.singer,songs.title,COALESCE(singers.name,'No singer') FROM queue JOIN songs ON songs.id=queue.song LEFT JOIN singers ON singers.id=queue.singer WHERE queue.venue=? ORDER BY position,id",[String(venue)]); defer { sqlite3_finalize(s) } + var result = [QueueEntry](); while sqlite3_step(s) == SQLITE_ROW { result.append(.init(id:sqlite3_column_int64(s,0),songID:sqlite3_column_int64(s,1),singerID:sqlite3_column_type(s,2) == SQLITE_NULL ? nil : sqlite3_column_int64(s,2),title:text(s,3),singer:text(s,4))) }; return result + } + func removeQueue(_ id: Int64) throws { try execute("DELETE FROM queue WHERE id=?",[String(id)]) } + func moveQueue(_ id: Int64, venue: Int64, by offset: Int) throws { + var rows = try queue(venue:venue) + guard let index = rows.firstIndex(where: { $0.id == id }), rows.indices.contains(index+offset) else { return } + rows.swapAt(index,index+offset) + try execute("BEGIN IMMEDIATE") + do { for (i,row) in rows.enumerated() { try execute("UPDATE queue SET position=? WHERE id=?",[String(i),String(row.id)]) }; try execute("COMMIT") } + catch { try? execute("ROLLBACK"); throw error } + } + func addToPlaylist(_ playlist: Int64, song: Int64) throws { + try execute("INSERT OR IGNORE INTO playlist_songs(playlist,song,position) VALUES(?,?,(SELECT COALESCE(MAX(position),0)+1 FROM playlist_songs WHERE playlist=?))",[String(playlist),String(song),String(playlist)]) + } + func playlistSongIDs(_ playlist: Int64) throws -> [Int64] { + let s = try statement("SELECT song FROM playlist_songs WHERE playlist=? ORDER BY position",[String(playlist)]); defer { sqlite3_finalize(s) } + var ids = [Int64](); while sqlite3_step(s) == SQLITE_ROW { ids.append(sqlite3_column_int64(s,0)) }; return ids + } + func removeFromPlaylist(_ playlist: Int64, song: Int64) throws { try execute("DELETE FROM playlist_songs WHERE playlist=? AND song=?",[String(playlist),String(song)]) } +} diff --git a/backups/before-venues-20261102-231352/KJamsClone/Player.swift b/backups/before-venues-20261102-231352/KJamsClone/Player.swift new file mode 100644 index 0000000..54b56af --- /dev/null +++ b/backups/before-venues-20261102-231352/KJamsClone/Player.swift @@ -0,0 +1,92 @@ +import SwiftUI +import AVFoundation + +/// Audio source-frame time drives CDG, including when the time-pitch unit changes tempo. +@MainActor final class Player: ObservableObject { + @Published var image: CGImage? + @Published var playing = false + @Published var time: Double = 0 + @Published var duration: Double = 0 + @Published var title = "Select a song" + @Published var volume: Double = 0.8 { didSet { engine.mainMixerNode.outputVolume = Float(volume) } } + @Published var repeating = false + @Published var pitch: Double = 0 { didSet { effect.pitch = Float(pitch * 100) } } + @Published var tempo: Double = 1 { didSet { effect.rate = Float(tempo) } } + @Published var graphicsOffset: Double = 0 + private(set) var currentSongID: Int64? + var onFinished: (() -> Void)? + private let engine = AVAudioEngine() + private let node = AVAudioPlayerNode() + private let effect = AVAudioUnitTimePitch() + private var file: AVAudioFile? + private var anchor: Double = 0 + private var generation = UUID() + private var cdg = Data() + private var decoder = CDGDecoder() + private var timer: Timer? + init() { engine.attach(node); engine.attach(effect) } + func load(_ song: Song, preference: SongPreference = .init()) throws { + stop() + do { + let loaded = try AVAudioFile(forReading: URL(fileURLWithPath:song.audio)) + let graphics = try song.cdg.map { try Data(contentsOf:URL(fileURLWithPath:$0)) } ?? Data() + guard loaded.length > 0 else { throw LibraryError.message("Audio file is empty") } + engine.connect(node,to:effect,format:loaded.processingFormat) + engine.connect(effect,to:engine.mainMixerNode,format:loaded.processingFormat) + file = loaded; cdg = graphics; decoder = CDGDecoder() + duration = Double(loaded.length) / loaded.processingFormat.sampleRate + title = song.title; currentSongID = song.id + pitch = preference.pitch; tempo = preference.tempo + engine.mainMixerNode.outputVolume = Float(volume) + engine.prepare(); try engine.start() + schedule(at:0, play:true); startTimer(); tick() + } catch { stop(); throw error } + } + private func schedule(at seconds: Double, play: Bool) { + guard let file else { return } + generation = UUID(); let token = generation + node.stop() + let rate = file.processingFormat.sampleRate + let start = min(file.length, max(0, AVAudioFramePosition(seconds * rate))) + anchor = Double(start) / rate; time = anchor + guard start < file.length else { playing = false; render(); return } + node.scheduleSegment(file, startingFrame:start, frameCount:AVAudioFrameCount(min(file.length-start,Int64(UInt32.max))), at:nil, completionCallbackType:.dataPlayedBack) { [weak self] _ in + Task { @MainActor in + guard let self, self.generation == token else { return } + if self.repeating { self.schedule(at:0,play:true) } + else { + self.playing = false; self.time = self.duration; self.render() + self.timer?.invalidate(); self.timer = nil; self.onFinished?() + } + } + } + if play { node.play() }; playing = play; render() + } + private func startTimer() { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval:1.0/30,repeats:true) { [weak self] _ in Task { @MainActor in self?.tick() } } + } + func toggle() { + guard file != nil else { return } + if playing { tick(); node.pause(); playing = false } + else if time >= duration { schedule(at:0,play:true); startTimer() } + else { node.play(); playing = true; startTimer() } + } + func stop() { + generation = UUID(); timer?.invalidate(); timer = nil; node.stop(); engine.stop() + engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect) + file = nil; playing = false; time = 0; anchor = 0; duration = 0; image = nil; currentSongID = nil; title = "Select a song" + } + func seek(_ value: Double) { + guard file != nil, value.isFinite else { return } + schedule(at:min(max(value,0),duration), play:playing) + startTimer() + } + private func render() { decoder.render(cdg,at:max(0,time+graphicsOffset)); image = cdg.isEmpty ? nil : decoder.image() } + private func tick() { + if playing, let file, let renderTime = node.lastRenderTime, let playerTime = node.playerTime(forNodeTime:renderTime) { + time = min(duration,anchor + Double(playerTime.sampleTime) / file.processingFormat.sampleRate) + } + render() + } +} diff --git a/backups/before-venues-20261102-231352/KJamsCloneTests/KJamsCloneTests.swift b/backups/before-venues-20261102-231352/KJamsCloneTests/KJamsCloneTests.swift new file mode 100644 index 0000000..0b1e3de --- /dev/null +++ b/backups/before-venues-20261102-231352/KJamsCloneTests/KJamsCloneTests.swift @@ -0,0 +1,100 @@ +import XCTest +@testable import KJamsClone + +final class KJamsCloneTests: XCTestCase { + func packet(_ instruction: UInt8, _ data: [UInt8]) -> [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] } + func testPresetTileXORAndInvalidCoordinates() { + var d = CDGDecoder() + d.apply(packet(1,[3,0])); XCTAssertEqual(d.pixels[0],3) + d.apply(packet(6,[0,5,1,1] + Array(repeating:63,count:12))) + XCTAssertEqual(d.pixels[12*300+6],5) + d.apply(packet(38,[0,5,1,1] + Array(repeating:63,count:12))) + XCTAssertEqual(d.pixels[12*300+6],0) + let old = d.pixels; d.apply(packet(6,[1,2,31,63])); XCTAssertEqual(d.pixels,old) + } + func testSeekAndTruncatedPacket() { + var d = CDGDecoder() + let data = Data(packet(1,[2,0]) + packet(1,[7,0]) + [9]) + d.render(data,at:1); XCTAssertEqual(d.pixels[0],7); XCTAssertEqual(d.packetIndex,2) + d.render(data,at:1.0/300); XCTAssertEqual(d.pixels[0],2) + d.render(data,at:0); XCTAssertEqual(d.pixels[0],0) + } + func testPaletteBorderScrollAndImage() { + var d = CDGDecoder() + d.apply(packet(30,[63,63])); XCTAssertEqual(d.palette[0],0xFFFFFFFF) + d.apply(packet(2,[4])); XCTAssertEqual(d.pixels[0],4); XCTAssertEqual(d.pixels[12*300+6],0) + d.apply(packet(20,[3,16,0])); XCTAssertEqual(d.pixels[100*300],3) + d.apply(packet(24,[0,32,0])); XCTAssertEqual(d.pixels[100*300+294],3) + XCTAssertEqual(d.image()?.width,288) + } + func testSQLitePersistenceDedupRatingsAndHistory() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let url = folder.appendingPathComponent("test.sqlite") + do { + let db = try Library(url:url) + try db.add(title:"Singer's Song",audio:"/test.mp3",cdg:nil) + try db.add(title:"Duplicate",audio:"/test.mp3",cdg:"/test.cdg") + let song = try XCTUnwrap(db.songs().first) + try db.rate(song.id,5); XCTAssertThrowsError(try db.rate(song.id,6)) + try db.addEntity("singers",name:"O'Neil"); try db.addEntity("venues",name:"Hall") + try db.record(song.id,singer:db.entities("singers").first?.id,venue:db.entities("venues").first?.id) + } + let reopened = try Library(url:url); let songs = try reopened.songs() + XCTAssertEqual(songs.count,1); XCTAssertEqual(songs[0].rating,5); XCTAssertEqual(songs[0].plays,1); XCTAssertEqual(songs[0].cdg,"/test.cdg") + XCTAssertEqual(try reopened.entities("singers").first?.name,"O'Neil") + } + func testPairedImportAndContentDedup() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let audio = root.appendingPathComponent("Test.mp3"), cdg = root.appendingPathComponent("Test.CDG") + try Data([1,2,3]).write(to:audio); try Data(packet(1,[2,0])).write(to:cdg) + let db = try Library(url:root.appendingPathComponent("test.sqlite")) + let importer = Importer(media:root.appendingPathComponent("Media")) + try importer.run([audio],library:db); try importer.run([audio],library:db) + XCTAssertEqual(try db.songs().count,1) + XCTAssertNotNil(try db.songs().first?.cdg) + } + + func testZippedMP3GImportAndUnsafeArchiveRejection() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let db = try Library(url:folder.appendingPathComponent("db.sqlite")) + let importer = Importer(media:folder.appendingPathComponent("Media")) + try importer.run([fixtures.appendingPathComponent("Demo.zip")],library:db) + let song = try XCTUnwrap(db.songs().first) + XCTAssertEqual(song.title,"Demo"); XCTAssertNotNil(song.cdg) + XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio)) + XCTAssertThrowsError(try importer.run([fixtures.appendingPathComponent("Unsafe.zip")],library:db)) + } + @MainActor func testMP3PlaybackPauseAndSeek() throws { + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let player = Player() + defer { player.stop() } + try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0)) + XCTAssertGreaterThan(player.duration,3.9); XCTAssertTrue(player.playing) + player.toggle(); XCTAssertFalse(player.playing) + player.seek(2); XCTAssertEqual(player.time,2,accuracy:0.05); XCTAssertNotNil(player.image) + player.seek(0.1); XCTAssertEqual(player.time,0.1,accuracy:0.05) + } + @MainActor func testCreateSingerAndVenueThroughAppModel() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let model = AppModel(storageURL: root) + XCTAssertNil(model.error) + let singer = try model.createEntity(.singer, name: " Alex ") + let venue = try model.createEntity(.venue, name: "Town Hall") + XCTAssertEqual(model.singers.first?.name, "Alex") + XCTAssertEqual(model.venues.first?.name, "Town Hall") + XCTAssertEqual(try model.createEntity(.singer, name: "Alex"), singer) + XCTAssertEqual(model.singers.count, 1) + XCTAssertThrowsError(try model.createEntity(.venue, name: " ")) + let reopened = AppModel(storageURL: root) + XCTAssertEqual(reopened.singers.first?.id, singer) + XCTAssertEqual(reopened.venues.first?.id, venue) + } +} diff --git a/backups/before-venues-20261102-231352/README.md b/backups/before-venues-20261102-231352/README.md new file mode 100644 index 0000000..a36ef98 --- /dev/null +++ b/backups/before-venues-20261102-231352/README.md @@ -0,0 +1,71 @@ +# KJamsClone — initial macOS implementation, v0.1.0 + +This is a real Xcode application project, not a Swift Package renamed as one. +Open **KJamsClone.xcodeproj**. Its required project file is +**KJamsClone.xcodeproj/project.pbxproj**. No version-control restoration, +project generator, third-party packages, or network downloads are needed. + +## Start + +1. Extract the whole source ZIP to a writable folder. Do not open the project from inside the archive. +2. Open `KJamsClone/KJamsClone.xcodeproj` in Xcode, select the **KJamsClone** scheme and **My Mac**, then Run. +3. Click the **+ import button** and select `Fixtures/Demo.zip` for an original four-second tone and animated tile demonstration. This is a decoder demonstration, not a song with lyrics. +4. Double-click the imported song. Use the **eye button** to open the Video window, then move it onto another monitor or use macOS's window fullscreen control. +5. Double-click a library row to play it; the large Play/Pause button resumes or pauses the current track. The smaller transport buttons seek back/forward ten seconds and play the next song. The volume slider and repeat button control actual playback. Use the + button to import and the eye button to open the Video window. +6. Import your own `.mp3` and matching `.cdg` files, or a ZIP containing those files. Names must match, ignoring case, in the same folder. Select the audio file; its sibling CDG is found automatically. Audio-only MP3/WAV/M4A and WAV+G are also accepted. + +App deployment target: macOS 13+. Verified here with Xcode 27.2 on Apple Silicon macOS 27.2; older systems and Intel execution have not been tested. The build produces arm64 and x86_64 slices. XCTest execution with this Xcode may require macOS 14+. + +## Implemented + +| Feature | Scope | +| --- | --- | +| macOS app | Screenshot-inspired charcoal/silver controls, navy source sidebar and library grid, blue status capsule, library/search, rating menu, separate Video window | +| MP3+G | AVAudioPlayer master clock; CDG packets decoded at 300 packets/sec; play, pause, resume, stop, seek, volume and repeat | +| CDG | Memory/border preset, normal/XOR tiles, low/high palette, scroll preset/copy with fine offsets, transparent color; 288×192 cropped graphics | +| ZIP import | Automatic extraction and pairing, nested folders, traversal/symlink rejection; system `/usr/bin/unzip` | +| Library | SQLite persistence, copied media, SHA-256 audio-content deduplication for the same extension, search by filename title | +| Ratings | Persistent per-song 0–5 stars | +| Singers / venues | Create, list, select; persistent SQLite records | +| Play history | Song/singer/venue/timestamp stored on each new song start; play counts shown | +| Tests | Eight XCTest cases covering decoder behavior, backward seeking, SQLite reopening, ratings, play counts, pair/dedup import, ZIP import/rejection, real MP3 loading/pause/seeking | +| Scripts | Debug build, tests, unsigned Release app ZIP | + +Library and copied media are stored under `~/Library/Application Support/KJamsClone/`. +Imported source files remain untouched. Back up that entire folder together; paths are absolute. +No music catalogue or copyrighted songs are included. Demo tone/graphics are original and may be used freely. + +## Incomplete / unverified + +This is **not a complete kJams replacement or production-qualified release**. +There is no singer rotation queue, playlist management, history browser, singer/venue editing or deletion, per-singer ratings/preferences, pitch/tempo adjustment, crossfade, key detection, metadata tag parsing, physical CD ripping, or standalone CDG-only playback. Titles come from filenames. The rating is global per song. Play counts count playback starts, not completed performances. + +Synthetic fixtures and automated MP3 checks passed; real commercial karaoke collections, long performances, audible output quality, visual lyric accuracy, external monitors, and full UI interactions have not been manually validated. The decoder ignores parity/error correction, unsupported instructions and channel selection; all tile channels are combined. CDG has no adjustable synchronization offset. Backward seeking rebuilds graphics from packet zero on the main thread. Large imports also run on the main thread, without progress/cancel controls or archive expansion limits; use trusted, reasonably sized archives. Importing multiple files is not one database/filesystem transaction. A failed import may leave copied media to clean up. The app is intentionally not sandboxed, signed for distribution, or notarized. It has the default macOS application icon. + +## Build, test, release + +Run from this extracted project folder: + +```sh +./scripts/build.sh +./scripts/test.sh +./scripts/release.sh +``` + +Scripts work from any working directory and stop on failure. Build products go under `build/` and `build-release/`; release output is `dist/KJamsClone-unsigned-macOS.zip`. Release builds disable signing. Developer ID signing and Apple notarization must be added before distributing the app to other users. The source ZIP contains neither personal media nor your library database. + +`VERIFICATION.md` records the checks performed on this delivery. `verification/` contains the corresponding build/test excerpts. + +## Implementation reference + +CDG packet layout and commands were implemented using Jim Bumgardner's [CD+G Revealed](https://jbum.com/cdg_revealed.html) as a format reference. The implementation is original Swift code; no third-party player source is bundled. + +## Screenshot-inspired interface + +The interface follows the supplied reference: charcoal frame, silver transport controls, blue status capsule, navy panes, compact column headings, source selection and bottom song count. Library, Singers and Venues are functional sources. Third-party music stores and playlists from the reference are not implemented. Artist, Album, Key Adjust and Tempo columns show dashes because those features are not available; the Label column identifies the imported format. Rating menus persist changes. The separate Video window remains black when no graphics are loaded. + +## Single main window and adding people / places + +The app uses a single main library window; the File menu has no New Window command. The optional Video display remains separate for karaoke output. + +Choose **File → New Singer…** or **File → New Venue…**, enter a name, then click Add. You can also use **New Singer… / New Venue…** at the top of the matching source pane, or the + button beside the singer/venue selector. New entries save to SQLite, appear immediately, and become selected for subsequent playback. Empty names are rejected, and adding an existing name selects its existing record. diff --git a/backups/before-venues-20261102-231352/verification/ui-preview.png b/backups/before-venues-20261102-231352/verification/ui-preview.png new file mode 100644 index 0000000..743dfe5 Binary files /dev/null and b/backups/before-venues-20261102-231352/verification/ui-preview.png differ diff --git a/backups/before-venues-20261102-231352/verification/xcode-test.log b/backups/before-venues-20261102-231352/verification/xcode-test.log new file mode 100644 index 0000000..48799b5 --- /dev/null +++ b/backups/before-venues-20261102-231352/verification/xcode-test.log @@ -0,0 +1,335 @@ +Command line invocation: + /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -project KJamsClone.xcodeproj -scheme KJamsClone -configuration Debug -destination platform=macOS -derivedDataPath "/Users/markgregory/Documents/ChatGPT/KJams Clone/KJamsClone/build" CODE_SIGNING_ALLOWED=NO test + +Build settings from command line: + CODE_SIGNING_ALLOWED = NO + +--- xcodebuild: WARNING: Using the first of multiple matching destinations: +{ platform:macOS, arch:arm64, id:00006030-000C01641102001C, name:My Mac } +{ platform:macOS, arch:x86_64, id:00006030-000C01641102001C, name:My Mac } +ComputePackagePrebuildTargetDependencyGraph + +Prepare packages + +CreateBuildRequest + +SendProjectDescription + +CreateBuildOperation + +ComputeTargetDependencyGraph +note: Building targets in dependency order +note: Target dependency graph (2 targets) + Target 'KJamsCloneTests' in project 'KJamsClone' + ➜ Explicit dependency on target 'KJamsClone' in project 'KJamsClone' + Target 'KJamsClone' in project 'KJamsClone' (no dependencies) + +GatherProvisioningInputs + +CreateBuildDescription + +ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc --version + +ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -v -E -dM -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -x c -c /dev/null + +ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ld -version_details + +Build description signature: a878cb40dda8511ba7445beb2d96bb25 +Build description path: /Users/markgregory/Documents/ChatGPT/KJams Clone/KJamsClone/build/Build/Intermediates.noindex/XCBuildData/a878cb40dda8511ba7445beb2d96bb25.xcbuilddata +ClangStatCache /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang-stat-cache /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-a7ff82d74745d5ef33555b35c4238456.sdkstatcache + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/KJamsClone.xcodeproj + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang-stat-cache /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -o /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-a7ff82d74745d5ef33555b35c4238456.sdkstatcache + +SwiftDriver KJamsClone normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-SwiftDriver -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -Onone @/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -F /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-generated-files.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-own-target-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-project-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone-Swift.h -working-directory /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone -experimental-emit-module-separately -disable-cmo + +SwiftDriver KJamsClone normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-SwiftDriver -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -Onone @/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -F /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-generated-files.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-own-target-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-project-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone-Swift.h -working-directory /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone -experimental-emit-module-separately -disable-cmo + +SwiftEmitModule normal x86_64 Emitting\ module\ for\ KJamsClone (in target 'KJamsClone' from project 'KJamsClone') + +SwiftCompile normal x86_64 (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + + +SwiftEmitModule normal arm64 Emitting\ module\ for\ KJamsClone (in target 'KJamsClone' from project 'KJamsClone') + +SwiftCompile normal arm64 (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + + +SwiftCompile normal arm64 Compiling\ KJamsCloneApp.swift /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/KJamsClone/KJamsCloneApp.swift (in target 'KJamsClone' from project 'KJamsClone') + +SwiftCompile normal arm64 /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/KJamsClone/KJamsCloneApp.swift (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-SwiftPerFileCompile KJamsCloneApp.swift + +SwiftCompile normal x86_64 Compiling\ KJamsCloneApp.swift /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/KJamsClone/KJamsCloneApp.swift (in target 'KJamsClone' from project 'KJamsClone') + +SwiftCompile normal x86_64 /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/KJamsClone/KJamsCloneApp.swift (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-SwiftPerFileCompile KJamsCloneApp.swift + +SwiftDriverJobDiscovery normal arm64 Emitting module for KJamsClone (in target 'KJamsClone' from project 'KJamsClone') + +SwiftDriver\ Compilation\ Requirements KJamsClone normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-Swift-Compilation-Requirements -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -Onone @/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -F /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-generated-files.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-own-target-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-project-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone-Swift.h -working-directory /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone -experimental-emit-module-separately -disable-cmo + +Copy /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone.swiftsourceinfo (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone.swiftsourceinfo /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo + +SwiftDriverJobDiscovery normal x86_64 Emitting module for KJamsClone (in target 'KJamsClone' from project 'KJamsClone') + +SwiftDriver\ Compilation\ Requirements KJamsClone normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-Swift-Compilation-Requirements -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -Onone @/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -F /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-generated-files.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-own-target-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-project-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone-Swift.h -working-directory /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone -experimental-emit-module-separately -disable-cmo + +Copy /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/arm64-apple-macos.swiftmodule /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone.swiftmodule (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone.swiftmodule /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/arm64-apple-macos.swiftmodule + +Copy /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/arm64-apple-macos.abi.json /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone.abi.json (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone.abi.json /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/arm64-apple-macos.abi.json + +Copy /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/x86_64-apple-macos.swiftmodule /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone.swiftmodule (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone.swiftmodule /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/x86_64-apple-macos.swiftmodule + +Copy /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/Project/x86_64-apple-macos.swiftsourceinfo /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone.swiftsourceinfo (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone.swiftsourceinfo /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/Project/x86_64-apple-macos.swiftsourceinfo + +Copy /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/x86_64-apple-macos.abi.json /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone.abi.json (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone.abi.json /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.swiftmodule/x86_64-apple-macos.abi.json + +SwiftDriver KJamsCloneTests normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-SwiftDriver -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsCloneTests -Onone @/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests.SwiftFileList -plugin-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/plugins/testing -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -Isystem /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -F /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -parse-as-library -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-generated-files.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-own-target-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-project-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests-Swift.h -working-directory /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone -experimental-emit-module-separately -disable-cmo + +SwiftDriver KJamsCloneTests normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-SwiftDriver -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsCloneTests -Onone @/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests.SwiftFileList -plugin-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/plugins/testing -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -Isystem /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -F /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -parse-as-library -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-generated-files.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-own-target-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-project-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests-Swift.h -working-directory /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone -experimental-emit-module-separately -disable-cmo + +SwiftEmitModule normal arm64 Emitting\ module\ for\ KJamsCloneTests (in target 'KJamsCloneTests' from project 'KJamsClone') + +SwiftCompile normal arm64 (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + + +SwiftEmitModule normal x86_64 Emitting\ module\ for\ KJamsCloneTests (in target 'KJamsCloneTests' from project 'KJamsClone') + +SwiftCompile normal x86_64 (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + + +SwiftCompile normal arm64 Compiling\ KJamsCloneTests.swift /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift (in target 'KJamsCloneTests' from project 'KJamsClone') + +SwiftCompile normal arm64 /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-SwiftPerFileCompile KJamsCloneTests.swift + +SwiftCompile normal x86_64 Compiling\ KJamsCloneTests.swift /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift (in target 'KJamsCloneTests' from project 'KJamsClone') + +SwiftCompile normal x86_64 /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-SwiftPerFileCompile KJamsCloneTests.swift + +SwiftDriverJobDiscovery normal arm64 Emitting module for KJamsCloneTests (in target 'KJamsCloneTests' from project 'KJamsClone') + +SwiftDriver\ Compilation\ Requirements KJamsCloneTests normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-Swift-Compilation-Requirements -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsCloneTests -Onone @/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests.SwiftFileList -plugin-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/plugins/testing -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -Isystem /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -F /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -parse-as-library -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-generated-files.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-own-target-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-project-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests-Swift.h -working-directory /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone -experimental-emit-module-separately -disable-cmo + +Copy /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsCloneTests.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests.swiftsourceinfo (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests.swiftsourceinfo /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsCloneTests.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo + +SwiftDriverJobDiscovery normal x86_64 Emitting module for KJamsCloneTests (in target 'KJamsCloneTests' from project 'KJamsClone') + +Copy /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsCloneTests.swiftmodule/arm64-apple-macos.swiftmodule /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests.swiftmodule (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests.swiftmodule /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsCloneTests.swiftmodule/arm64-apple-macos.swiftmodule + +Copy /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsCloneTests.swiftmodule/arm64-apple-macos.abi.json /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests.abi.json (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests.abi.json /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsCloneTests.swiftmodule/arm64-apple-macos.abi.json + +SwiftDriver\ Compilation\ Requirements KJamsCloneTests normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-Swift-Compilation-Requirements -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsCloneTests -Onone @/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests.SwiftFileList -plugin-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/plugins/testing -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -Isystem /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -F /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -parse-as-library -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-generated-files.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-own-target-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-project-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests-Swift.h -working-directory /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone -experimental-emit-module-separately -disable-cmo + +Copy /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsCloneTests.swiftmodule/x86_64-apple-macos.abi.json /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests.abi.json (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests.abi.json /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsCloneTests.swiftmodule/x86_64-apple-macos.abi.json + +Copy /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsCloneTests.swiftmodule/x86_64-apple-macos.swiftmodule /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests.swiftmodule (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests.swiftmodule /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsCloneTests.swiftmodule/x86_64-apple-macos.swiftmodule + +Copy /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsCloneTests.swiftmodule/Project/x86_64-apple-macos.swiftsourceinfo /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests.swiftsourceinfo (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests.swiftsourceinfo /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsCloneTests.swiftmodule/Project/x86_64-apple-macos.swiftsourceinfo + +SwiftDriverJobDiscovery normal x86_64 Compiling KJamsCloneTests.swift (in target 'KJamsCloneTests' from project 'KJamsClone') + +SwiftDriverJobDiscovery normal arm64 Compiling KJamsCloneTests.swift (in target 'KJamsCloneTests' from project 'KJamsClone') + +SwiftDriver\ Compilation KJamsCloneTests normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-Swift-Compilation -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsCloneTests -Onone @/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests.SwiftFileList -plugin-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/plugins/testing -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -Isystem /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -F /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -parse-as-library -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-generated-files.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-own-target-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-project-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests-Swift.h -working-directory /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone -experimental-emit-module-separately -disable-cmo + +SwiftDriver\ Compilation KJamsCloneTests normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-Swift-Compilation -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsCloneTests -Onone @/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests.SwiftFileList -plugin-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/plugins/testing -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -Isystem /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -F /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -parse-as-library -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-generated-files.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-own-target-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests-project-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests-Swift.h -working-directory /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone -experimental-emit-module-separately -disable-cmo + +SwiftDriverJobDiscovery normal x86_64 Compiling KJamsCloneApp.swift (in target 'KJamsClone' from project 'KJamsClone') + +SwiftDriver\ Compilation KJamsClone normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-Swift-Compilation -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -Onone @/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -F /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-generated-files.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-own-target-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-project-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone-Swift.h -working-directory /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone -experimental-emit-module-separately -disable-cmo + +Ld /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/Binary/KJamsClone.debug.dylib normal x86_64 (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target x86_64-apple-macos13.0 -dynamiclib -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -Os -Xlinker -no_warn_duplicate_libraries -L/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -L/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -F/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -F/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -filelist /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone.LinkFileList -install_name @rpath/KJamsClone.debug.dylib -Xlinker -rpath -Xlinker /usr/lib/swift -Xlinker -object_path_lto -Xlinker /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone_lto.o -rdynamic -Xlinker -dependency_info -Xlinker /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone_dependency_info.dat -fobjc-link-runtime -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx -L/usr/lib/swift -Xlinker -add_ast_path -Xlinker /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone.swiftmodule -lsqlite3 -Xlinker -alias -Xlinker _main -Xlinker ___debug_main_executable_dylib_entry_point -o /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/Binary/KJamsClone.debug.dylib + +SwiftDriverJobDiscovery normal arm64 Compiling KJamsCloneApp.swift (in target 'KJamsClone' from project 'KJamsClone') + +SwiftDriver\ Compilation KJamsClone normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-Swift-Compilation -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -Onone @/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -F /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-generated-files.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-own-target-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-project-headers.hmap -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone-Swift.h -working-directory /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone -experimental-emit-module-separately -disable-cmo + +Ld /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/Binary/KJamsClone.debug.dylib normal arm64 (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target arm64-apple-macos13.0 -dynamiclib -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -Os -Xlinker -no_warn_duplicate_libraries -L/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -L/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -F/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -F/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -filelist /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone.LinkFileList -install_name @rpath/KJamsClone.debug.dylib -Xlinker -rpath -Xlinker /usr/lib/swift -Xlinker -object_path_lto -Xlinker /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone_lto.o -rdynamic -Xlinker -dependency_info -Xlinker /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone_dependency_info.dat -fobjc-link-runtime -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx -L/usr/lib/swift -Xlinker -add_ast_path -Xlinker /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone.swiftmodule -lsqlite3 -Xlinker -alias -Xlinker _main -Xlinker ___debug_main_executable_dylib_entry_point -o /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/Binary/KJamsClone.debug.dylib + +ConstructStubExecutorLinkFileList /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-ExecutorLinkFileList-normal-x86_64.txt (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + construct-stub-executor-link-file-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/Binary/KJamsClone.debug.dylib /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/libPreviewsJITStubExecutor_no_swift_entry_point.a /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/libPreviewsJITStubExecutor.a --output /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-ExecutorLinkFileList-normal-x86_64.txt +note: Using stub executor library with Swift entry point. (in target 'KJamsClone' from project 'KJamsClone') + +ConstructStubExecutorLinkFileList /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-ExecutorLinkFileList-normal-arm64.txt (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + construct-stub-executor-link-file-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/Binary/KJamsClone.debug.dylib /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/libPreviewsJITStubExecutor_no_swift_entry_point.a /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/libPreviewsJITStubExecutor.a --output /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-ExecutorLinkFileList-normal-arm64.txt +note: Using stub executor library with Swift entry point. (in target 'KJamsClone' from project 'KJamsClone') + +CreateUniversalBinary /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/MacOS/KJamsClone.debug.dylib normal arm64\ x86_64 (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/lipo -create /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/Binary/KJamsClone.debug.dylib /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/Binary/KJamsClone.debug.dylib -output /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/MacOS/KJamsClone.debug.dylib + +Ld /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/Binary/KJamsClone normal x86_64 (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target x86_64-apple-macos13.0 -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -Os -Xlinker -no_warn_duplicate_libraries -L/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -F/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -Xlinker -rpath -Xlinker @executable_path -rdynamic -e ___debug_blank_executor_main -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __debug_dylib -Xlinker /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-DebugDylibPath-normal-x86_64.txt -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __debug_instlnm -Xlinker /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-DebugDylibInstallName-normal-x86_64.txt -Xlinker -filelist -Xlinker /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-ExecutorLinkFileList-normal-x86_64.txt /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/Binary/KJamsClone.debug.dylib -o /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/Binary/KJamsClone + +Ld /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/Binary/KJamsClone normal arm64 (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target arm64-apple-macos13.0 -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -Os -Xlinker -no_warn_duplicate_libraries -L/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -F/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -Xlinker -rpath -Xlinker @executable_path -rdynamic -e ___debug_blank_executor_main -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __debug_dylib -Xlinker /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-DebugDylibPath-normal-arm64.txt -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __debug_instlnm -Xlinker /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-DebugDylibInstallName-normal-arm64.txt -Xlinker -filelist -Xlinker /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone-ExecutorLinkFileList-normal-arm64.txt /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/Binary/KJamsClone.debug.dylib -o /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/Binary/KJamsClone + +CreateUniversalBinary /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/MacOS/KJamsClone normal arm64\ x86_64 (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/lipo -create /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/Binary/KJamsClone /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/Binary/KJamsClone -output /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/MacOS/KJamsClone + +CopySwiftLibs /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-swiftStdLibTool --copy --verbose --scan-executable /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/MacOS/KJamsClone.debug.dylib --scan-folder /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/Frameworks --scan-folder /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/PlugIns --scan-folder /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/Library/SystemExtensions --scan-folder /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/Extensions --platform macosx --toolchain /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain --destination /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/Frameworks --strip-bitcode --strip-bitcode-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/bitcode_strip --emit-dependency-info /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/SwiftStdLibToolInputDependencies.dep --filter-for-swift-os --back-deploy-swift-span +Ignoring --strip-bitcode because --sign was not passed + +ExtractAppIntentsMetadata (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/appintentsmetadataprocessor --toolchain-dir /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain --module-name KJamsClone --sdk-root /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk --xcode-version 27B52 --platform-family macOS --deployment-target 13.0 --bundle-identifier com.kjamsclone.app --output /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/Resources --target-triple arm64-apple-macos13.0 --target-triple x86_64-apple-macos13.0 --binary-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/MacOS/KJamsClone --dependency-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone_dependency_info.dat --dependency-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone_dependency_info.dat --stringsdata-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/ExtractedAppShortcutsMetadata.stringsdata --stringsdata-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/ExtractedAppShortcutsMetadata.stringsdata --source-file-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone.SwiftFileList --source-file-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone.SwiftFileList --metadata-file-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone.DependencyMetadataFileList --static-metadata-file-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/KJamsClone.DependencyStaticMetadataFileList --swift-const-vals-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/arm64/KJamsClone.SwiftConstValuesFileList --swift-const-vals-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/Objects-normal/x86_64/KJamsClone.SwiftConstValuesFileList --compile-time-extraction --deployment-aware-processing --no-app-shortcuts-localization +2026-11-02 22:56:14.618 appintentsmetadataprocessor[18810:6291935] Starting App Intents metadata extraction +2026-11-02 22:56:14.619 appintentsmetadataprocessor[18810:6291935] warning: Metadata extraction skipped, no AppIntents.framework dependency found + +ProcessInfoPlistFile /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/Info.plist /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/empty-KJamsClone.plist (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-infoPlistUtility /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsClone.build/empty-KJamsClone.plist -producttype com.apple.product-type.application -genpkginfo /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/PkgInfo -expandbuildsettings -platform macosx -o /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/Info.plist + +Ld /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/Binary/KJamsCloneTests normal arm64 (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target arm64-apple-macos13.0 -bundle -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -Os -Xlinker -no_warn_duplicate_libraries -L/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -L/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -L/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -F/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -iframework /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -filelist /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests.LinkFileList -Xlinker -rpath -Xlinker /usr/lib/swift -Xlinker -rpath -Xlinker @loader_path/../Frameworks -Xlinker -rpath -Xlinker @executable_path/../Frameworks -bundle_loader /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/MacOS/KJamsClone -Xlinker -object_path_lto -Xlinker /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests_lto.o -rdynamic -Xlinker -dependency_info -Xlinker /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests_dependency_info.dat -fobjc-link-runtime -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx -L/usr/lib/swift -Xlinker -add_ast_path -Xlinker /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests.swiftmodule -lsqlite3 -Xlinker -needed_framework -Xlinker XCTest -framework XCTest -Xlinker -needed-lXCTestSwiftSupport -lXCTestSwiftSupport /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/MacOS/KJamsClone.debug.dylib -o /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/Binary/KJamsCloneTests +/Users/markgregory/Documents/ChatGPT/KJams Clone/KJamsClone/KJamsClone.xcodeproj: KJamsCloneTests: ld: warning: building for macOS-13.0, but linking with dylib '@rpath/XCTest.framework/Versions/A/XCTest' which was built for newer version 14.0 +/Users/markgregory/Documents/ChatGPT/KJams Clone/KJamsClone/KJamsClone.xcodeproj: KJamsCloneTests: ld: warning: building for macOS-13.0, but linking with dylib '@rpath/libXCTestSwiftSupport.dylib' which was built for newer version 14.0 + +Ld /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/Binary/KJamsCloneTests normal x86_64 (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target x86_64-apple-macos13.0 -bundle -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -Os -Xlinker -no_warn_duplicate_libraries -L/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -L/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -L/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -F/Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug -iframework /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -filelist /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests.LinkFileList -Xlinker -rpath -Xlinker /usr/lib/swift -Xlinker -rpath -Xlinker @loader_path/../Frameworks -Xlinker -rpath -Xlinker @executable_path/../Frameworks -bundle_loader /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/MacOS/KJamsClone -Xlinker -object_path_lto -Xlinker /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests_lto.o -rdynamic -Xlinker -dependency_info -Xlinker /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests_dependency_info.dat -fobjc-link-runtime -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx -L/usr/lib/swift -Xlinker -add_ast_path -Xlinker /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests.swiftmodule -lsqlite3 -Xlinker -needed_framework -Xlinker XCTest -framework XCTest -Xlinker -needed-lXCTestSwiftSupport -lXCTestSwiftSupport /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/MacOS/KJamsClone.debug.dylib -o /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/Binary/KJamsCloneTests +/Users/markgregory/Documents/ChatGPT/KJams Clone/KJamsClone/KJamsClone.xcodeproj: KJamsCloneTests: ld: warning: building for macOS-13.0, but linking with dylib '@rpath/XCTest.framework/Versions/A/XCTest' which was built for newer version 14.0 +/Users/markgregory/Documents/ChatGPT/KJams Clone/KJamsClone/KJamsClone.xcodeproj: KJamsCloneTests: ld: warning: building for macOS-13.0, but linking with dylib '@rpath/libXCTestSwiftSupport.dylib' which was built for newer version 14.0 + +ProcessInfoPlistFile /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/PlugIns/KJamsCloneTests.xctest/Contents/Info.plist /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/empty-KJamsCloneTests.plist (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-infoPlistUtility /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/empty-KJamsCloneTests.plist -producttype com.apple.product-type.bundle.unit-test -expandbuildsettings -platform macosx -o /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/PlugIns/KJamsCloneTests.xctest/Contents/Info.plist + +CreateUniversalBinary /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/PlugIns/KJamsCloneTests.xctest/Contents/MacOS/KJamsCloneTests normal arm64\ x86_64 (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/lipo -create /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/Binary/KJamsCloneTests /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/Binary/KJamsCloneTests -output /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/PlugIns/KJamsCloneTests.xctest/Contents/MacOS/KJamsCloneTests + +CopySwiftLibs /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/PlugIns/KJamsCloneTests.xctest (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-swiftStdLibTool --copy --verbose --scan-executable /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/PlugIns/KJamsCloneTests.xctest/Contents/MacOS/KJamsCloneTests --scan-folder /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/PlugIns/KJamsCloneTests.xctest/Contents/Frameworks --scan-folder /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/PlugIns/KJamsCloneTests.xctest/Contents/PlugIns --scan-folder /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/PlugIns/KJamsCloneTests.xctest/Contents/Library/SystemExtensions --scan-folder /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/PlugIns/KJamsCloneTests.xctest/Contents/Extensions --platform macosx --toolchain /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain --destination /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/PlugIns/KJamsCloneTests.xctest/Contents/Frameworks --strip-bitcode --scan-executable /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/libXCTestSwiftSupport.dylib --strip-bitcode-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/bitcode_strip --emit-dependency-info /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/SwiftStdLibToolInputDependencies.dep --filter-for-swift-os --back-deploy-swift-span +Ignoring --strip-bitcode because --sign was not passed + +ExtractAppIntentsMetadata (in target 'KJamsCloneTests' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/appintentsmetadataprocessor --toolchain-dir /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain --module-name KJamsCloneTests --sdk-root /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk --xcode-version 27B52 --platform-family macOS --deployment-target 13.0 --bundle-identifier com.kjamsclone.tests --output /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/PlugIns/KJamsCloneTests.xctest/Contents/Resources --target-triple arm64-apple-macos13.0 --target-triple x86_64-apple-macos13.0 --binary-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app/Contents/PlugIns/KJamsCloneTests.xctest/Contents/MacOS/KJamsCloneTests --dependency-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests_dependency_info.dat --dependency-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests_dependency_info.dat --stringsdata-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/ExtractedAppShortcutsMetadata.stringsdata --stringsdata-file /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/ExtractedAppShortcutsMetadata.stringsdata --source-file-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests.SwiftFileList --source-file-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests.SwiftFileList --metadata-file-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests.DependencyMetadataFileList --static-metadata-file-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/KJamsCloneTests.DependencyStaticMetadataFileList --swift-const-vals-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/arm64/KJamsCloneTests.SwiftConstValuesFileList --swift-const-vals-list /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/KJamsClone.build/Debug/KJamsCloneTests.build/Objects-normal/x86_64/KJamsCloneTests.SwiftConstValuesFileList --compile-time-extraction --deployment-aware-processing --no-app-shortcuts-localization +2026-11-02 22:56:14.898 appintentsmetadataprocessor[18816:6291965] Starting App Intents metadata extraction +2026-11-02 22:56:14.900 appintentsmetadataprocessor[18816:6291965] warning: Metadata extraction skipped, no AppIntents.framework dependency found + +Validate /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-validationUtility /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app -no-validate-extension -infoplist-subpath Contents/Info.plist + +RegisterWithLaunchServices /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app (in target 'KJamsClone' from project 'KJamsClone') + cd /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone + builtin-lsregisterurl --record-path /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Intermediates.noindex/XCBuildData/registered-launchservices.txt -- /System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister -f -R -trusted /Users/markgregory/Documents/ChatGPT/KJams\ Clone/KJamsClone/build/Build/Products/Debug/KJamsClone.app + +PruneExplicitPrecompiledModules /Users/markgregory/Documents/ChatGPT/KJams Clone/KJamsClone/build/SDKExplicitPrecompiledModules + +PruneExplicitPrecompiledModules /Users/markgregory/Documents/ChatGPT/KJams Clone/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules + +PruneExplicitPrecompiledModules /Users/markgregory/Documents/ChatGPT/KJams Clone/KJamsClone/build/Build/Intermediates.noindex/ExplicitPrecompiledModules + +2026-11-02 22:56:15.285436+0000 KJamsClone[18818:6292041] [Connection] Unable to get remoteObjectProxy, error: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.linkd.autoShortcut" UserInfo={NSDebugDescription=connection to service named com.apple.linkd.autoShortcut} +2026-11-02 22:56:15.285622+0000 KJamsClone[18818:6292041] [Application] Error registering app with AppIntents framework: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.linkd.autoShortcut" UserInfo={NSDebugDescription=connection to service named com.apple.linkd.autoShortcut} +2026-11-02 22:56:15.314863+0000 KJamsClone[18818:6292016] ApplePersistenceIgnoreState: Existing state will not be touched. New state will be written to /var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/com.kjamsclone.app.savedState +Test Suite 'All tests' started at 2026-11-02 22:56:15.497. +Test Suite 'KJamsCloneTests.xctest' started at 2026-11-02 22:56:15.497. +Test Suite 'KJamsCloneTests' started at 2026-11-02 22:56:15.497. +Test Case '-[KJamsCloneTests.KJamsCloneTests testCreateSingerAndVenueThroughAppModel]' started. +Test Case '-[KJamsCloneTests.KJamsCloneTests testCreateSingerAndVenueThroughAppModel]' passed (0.004 seconds). +Test Case '-[KJamsCloneTests.KJamsCloneTests testMP3PlaybackPauseAndSeek]' started. +2026-11-02 22:56:15.520156+0000 KJamsClone[18818:6292016] [plugin] AddInstanceForFactory: No factory registered for id F8BB1C28-BAE8-11D6-9C31-00039315CD46 +Test Case '-[KJamsCloneTests.KJamsCloneTests testMP3PlaybackPauseAndSeek]' passed (0.198 seconds). +Test Case '-[KJamsCloneTests.KJamsCloneTests testPairedImportAndContentDedup]' started. +Test Case '-[KJamsCloneTests.KJamsCloneTests testPairedImportAndContentDedup]' passed (0.005 seconds). +Test Case '-[KJamsCloneTests.KJamsCloneTests testPaletteBorderScrollAndImage]' started. +Test Case '-[KJamsCloneTests.KJamsCloneTests testPaletteBorderScrollAndImage]' passed (0.032 seconds). +Test Case '-[KJamsCloneTests.KJamsCloneTests testPresetTileXORAndInvalidCoordinates]' started. +Test Case '-[KJamsCloneTests.KJamsCloneTests testPresetTileXORAndInvalidCoordinates]' passed (0.001 seconds). +Test Case '-[KJamsCloneTests.KJamsCloneTests testSeekAndTruncatedPacket]' started. +Test Case '-[KJamsCloneTests.KJamsCloneTests testSeekAndTruncatedPacket]' passed (0.001 seconds). +Test Case '-[KJamsCloneTests.KJamsCloneTests testSQLitePersistenceDedupRatingsAndHistory]' started. +Test Case '-[KJamsCloneTests.KJamsCloneTests testSQLitePersistenceDedupRatingsAndHistory]' passed (0.004 seconds). +Test Case '-[KJamsCloneTests.KJamsCloneTests testZippedMP3GImportAndUnsafeArchiveRejection]' started. +Test Case '-[KJamsCloneTests.KJamsCloneTests testZippedMP3GImportAndUnsafeArchiveRejection]' passed (0.016 seconds). +Test Suite 'KJamsCloneTests' passed at 2026-11-02 22:56:15.760. + Executed 8 tests, with 0 failures (0 unexpected) in 0.262 (0.263) seconds +Test Suite 'KJamsCloneTests.xctest' passed at 2026-11-02 22:56:15.760. + Executed 8 tests, with 0 failures (0 unexpected) in 0.262 (0.263) seconds +Test Suite 'All tests' passed at 2026-11-02 22:56:15.760. + Executed 8 tests, with 0 failures (0 unexpected) in 0.262 (0.264) seconds +2026-11-02 22:56:16.045 xcodebuild[18779:6291628] [MT] IDETestOperationsObserverDebug: 1.114 elapsed -- Testing started completed. +2026-11-02 22:56:16.045 xcodebuild[18779:6291628] [MT] IDETestOperationsObserverDebug: 0.000 sec, +0.000 sec -- start +2026-11-02 22:56:16.045 xcodebuild[18779:6291628] [MT] IDETestOperationsObserverDebug: 1.114 sec, +1.114 sec -- end + +Test session results, code coverage, and logs: + /Users/markgregory/Documents/ChatGPT/KJams Clone/KJamsClone/build/Logs/Test/Test-KJamsClone-2026.11.02_22-56-11-+0000.xcresult + +** TEST SUCCEEDED ** + +Testing started diff --git a/backups/before-waveform-20261103-082733/KJamsClone/KJamsCloneApp.swift b/backups/before-waveform-20261103-082733/KJamsClone/KJamsCloneApp.swift new file mode 100644 index 0000000..8fcc73e --- /dev/null +++ b/backups/before-waveform-20261103-082733/KJamsClone/KJamsCloneApp.swift @@ -0,0 +1,636 @@ +import SwiftUI +import UniformTypeIdentifiers + +enum EntityKind: String, Identifiable { + case singer, venue + var id: String { rawValue } + var title: String { self == .singer ? "Singer" : "Venue" } + var table: String { self == .singer ? "singers" : "venues" } +} + +@MainActor final class AppModel: ObservableObject { + @Published var songs = [Song]() + @Published var singers = [PersonPlace]() + @Published var venues = [PersonPlace]() + @Published var venueSingers = [PersonPlace]() + @Published var playlists = [PersonPlace]() + @Published var queue = [QueueEntry]() + @Published var history = [Performance]() + @Published private(set) var currentVenue: Int64? + @Published var selectedSinger: Int64? + @Published var autoAdvance = true + @Published var error: String? + @Published var creating: EntityKind? + let player = Player() + private(set) var library: Library? + let root: URL + var currentVenueName: String { venues.first { $0.id == currentVenue }?.name ?? "No venue selected" } + init(storageURL: URL? = nil) { + root = storageURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("KJamsClone") + perform { + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + library = try Library(url:root.appendingPathComponent("Library.sqlite")); try refresh() + } + player.onFinished = { [weak self] in + guard let self, self.autoAdvance, !self.queue.isEmpty else { return } + self.playQueueNext() + } + } + func perform(_ work: () throws -> Void) { do { try work() } catch { self.error = error.localizedDescription } } + func refresh() throws { + guard let library else { throw LibraryError.message("Library unavailable") } + songs = try library.songs(); singers = try library.entities("singers"); venues = try library.entities("venues") + playlists = try library.entities("playlists"); currentVenue = try library.currentVenue() + venueSingers = try currentVenue.map { try library.members(of:$0) } ?? singers + if let selectedSinger, !venueSingers.contains(where: { $0.id == selectedSinger }) { self.selectedSinger = nil } + queue = try library.queue(venue:currentVenue); history = try library.history(venue:currentVenue) + } + func chooseVenue(_ id: Int64?) { + perform { + guard let library else { throw LibraryError.message("Library unavailable") } + try library.selectVenue(id); selectedSinger = nil; try refresh() + } + } + func membership(singer: Int64, included: Bool) { + perform { + guard let library, let currentVenue else { throw LibraryError.message("Select a current venue first") } + try library.setMembership(singer:singer,venue:currentVenue,included:included); try refresh() + } + } + @discardableResult func createEntity(_ kind: EntityKind, name: String) throws -> Int64 { + guard let library else { throw LibraryError.message("Library unavailable") } + let trimmed = name.trimmingCharacters(in:.whitespacesAndNewlines) + try library.addEntity(kind.table,name:trimmed) + guard let entity = try library.entities(kind.table).first(where: { $0.name == trimmed }) else { throw LibraryError.message("Could not load new entry") } + if kind == .singer { + if let currentVenue { try library.setMembership(singer:entity.id,venue:currentVenue,included:true) } + selectedSinger = entity.id + } else { try library.selectVenue(entity.id); selectedSinger = nil } + try refresh(); return entity.id + } + func rename(_ table: String, id: Int64, name: String) { perform { try library?.rename(table,id:id,name:name); try refresh() } } + func delete(_ table: String, id: Int64) { perform { try library?.deleteEntity(table,id:id); try refresh() } } + func preference(_ song: Int64) -> SongPreference { + guard let library, let selectedSinger else { return .init() } + return (try? library.preference(song:song,singer:selectedSinger)) ?? .init() + } + func savePreference(song: Int64, value: SongPreference) { + perform { + guard let library, let selectedSinger else { throw LibraryError.message("Select a singer to save their song preferences") } + try library.savePreference(song:song,singer:selectedSinger,value:value); try refresh() + } + } + func play(_ song: Song, singer: Int64? = nil) throws { + guard let library else { throw LibraryError.message("Library unavailable") } + let singer = singer ?? selectedSinger + if let singer, let currentVenue, try !library.members(of:currentVenue).contains(where: { $0.id == singer }) { throw LibraryError.message("This singer is not in the current venue") } + let preference = try singer.map { try library.preference(song:song.id,singer:$0) } ?? SongPreference() + try player.load(song,preference:preference) + do { try library.record(song.id,singer:singer,venue:currentVenue); selectedSinger = singer; try refresh() } + catch { player.stop(); throw error } + } + func enqueue(_ song: Int64) { perform { try library?.enqueue(song:song,singer:selectedSinger,venue:currentVenue); try refresh() } } + func playQueueNext() { + perform { + guard let row = queue.first, let song = songs.first(where: { $0.id == row.songID }) else { return } + // Explicitly clear selection for unassigned queue entries. + selectedSinger = row.singerID + try play(song,singer:row.singerID); try library?.removeQueue(row.id); try refresh() + } + } + func moveQueue(_ id: Int64, by offset: Int) { perform { if let currentVenue { try library?.moveQueue(id,venue:currentVenue,by:offset); try refresh() } } } + func removeQueue(_ id: Int64) { perform { try library?.removeQueue(id); try refresh() } } + func createPlaylist(_ name: String) { perform { try library?.addEntity("playlists",name:name); try refresh() } } + func addToPlaylist(_ playlist: Int64, song: Int64) { perform { try library?.addToPlaylist(playlist,song:song); try refresh() } } + func importFiles(_ urls: [URL]) { perform { guard let library else { throw LibraryError.message("Library unavailable") }; try Importer(media:root.appendingPathComponent("Media")).run(urls,library:library); try refresh() } } +} +@main struct KJamsCloneApp: App { + @StateObject private var model = AppModel() + var body: some Scene { + Window("KJamsClone", id: "main") { + LibraryView(model: model).frame(minWidth: 1060, minHeight: 680).preferredColorScheme(.dark) + }.defaultSize(width: 1260, height: 840) + .commands { + CommandGroup(replacing: .newItem) { + Button("New Singer…") { model.creating = .singer }.keyboardShortcut("n", modifiers: [.command, .shift]) + Button("New Venue…") { model.creating = .venue }.keyboardShortcut("n", modifiers: [.command, .option]) + } + } + Window("Video", id: "lyrics") { + LyricsView(player: model.player).frame(minWidth: 480, minHeight: 320).preferredColorScheme(.dark) + }.defaultSize(width: 600, height: 400) + } +} +private enum Classic { + static let frame = Color(red: 0.15, green: 0.15, blue: 0.15) + static let navy = Color(red: 0.008, green: 0.065, blue: 0.16) + static let blue = Color(red: 0.015, green: 0.17, blue: 0.42) + static let header = Color(white: 0.115) + static let line = Color(white: 0.42) +} +struct LyricsView: View { + @ObservedObject var player: Player + var body: some View { + ZStack { + Color.black + if let image = player.image { + Image(decorative: image, scale: 1).interpolation(.none).resizable().aspectRatio(1.5, contentMode: .fit) + } + } + } +} +/// This is another view of the same published CGImage used by the Video window. +/// It owns no decoder, audio player or clock, so seeking/pause/tempo stay identical. +private struct SidebarVideoMonitor: View { + let player: Player + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Label("Video Monitor", systemImage: "tv") + .font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7)) + LyricsView(player: player) + .aspectRatio(1.5, contentMode: .fit) + .clipped() + .overlay(Rectangle().strokeBorder(.white.opacity(0.25), lineWidth: 1)) + .accessibilityLabel("Video monitor, synchronized with the Video window") + }.frame(maxWidth: .infinity) + } +} +private struct SilverSquareStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label.foregroundStyle(Color(white: 0.25)).padding(.horizontal, 6).padding(.vertical, 3) + .background(LinearGradient(colors: [Color(white: 0.95), Color(white: 0.64)], startPoint: .top, endPoint: .bottom), in: RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Color(white: 0.45), lineWidth: 1.5)) + .overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(.white.opacity(0.7)).padding(3)) + .opacity(configuration.isPressed ? 0.7 : 1) + } +} +private struct SilverButton: View { + let symbol: String + let label: String + var size: CGFloat = 40 + var active = false + var enabled = true + let action: () -> Void + var body: some View { + Button(action: action) { + Image(systemName: symbol).font(.system(size: size * 0.41, weight: .semibold)) + .foregroundStyle(active ? Color.blue : Color(white: 0.24)) + .frame(width: size, height: size) + .background(LinearGradient(colors: [.white, Color(white: 0.85), Color(white: 0.63)], startPoint: .top, endPoint: .bottom), in: Circle()) + .overlay(Circle().strokeBorder(Color(white: 0.45), lineWidth: 1.5)) + .overlay(Circle().strokeBorder(.white.opacity(0.65), lineWidth: 1).padding(3)) + .shadow(color: .black.opacity(0.7), radius: 1, y: 1) + }.buttonStyle(.plain).disabled(!enabled).opacity(enabled ? 1 : 0.72) + .help(label).accessibilityLabel(label) + } +} +struct LibraryView: View { + @ObservedObject var model: AppModel + @State private var selected: Int64? + @State private var search = "" + @State private var name = "" + @State private var importing = false + @State private var source = "Library" + @Environment(\.openWindow) private var openWindow + private var visibleSongs: [Song] { model.songs.filter { search.isEmpty || ($0.title+" "+$0.artist+" "+$0.album).localizedCaseInsensitiveContains(search) } } + private var selectedSong: Song? { model.songs.first { $0.id == selected } } + var body: some View { + VStack(spacing: 0) { + TransportView(player: model.player, search: $search, hasSelection: selectedSong != nil, + play: playSelected, next: playNext, display: { openWindow(id: "lyrics") }) + .padding(.horizontal, 18).padding(.top, 18).padding(.bottom, 22) + HSplitView { + sidebar.frame(minWidth: 190, idealWidth: 220, maxWidth: 240) + VStack(spacing: 12) { + if source == "Library" { songTable } + else if source == "Queue" { queuePane } + else if source == "History" { historyPane } + else if source == "Playlists" { playlistPane } + else { directory } + detailPanel.frame(height: 135) + }.padding(.leading, 12).frame(minWidth: 780) + }.padding(.horizontal, 18) + footer.padding(.horizontal, 18).padding(.vertical, 12) + } + .background(Classic.frame) + .sheet(item: $model.creating) { kind in + AddEntitySheet(model: model, kind: kind) { id in + if kind == .singer { model.selectedSinger = id } else { model.chooseVenue(id) } + } + } + .fileImporter(isPresented: $importing, allowedContentTypes: [.audio, .zip, UTType(filenameExtension: "cdg") ?? .data], allowsMultipleSelection: true) { result in + switch result { + case .success(let urls): model.importFiles(urls) + case .failure(let error): model.error = error.localizedDescription + } + } + .alert("KJamsClone", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { + Button("OK") { model.error = nil } + } message: { Text(model.error ?? "") } + } + private func sourceCount(_ source: String) -> Int { + switch source { + case "Library": return model.songs.count + case "Singers": return model.venueSingers.count + case "Venues": return model.venues.count + case "Queue": return model.queue.count + case "History": return model.history.count + default: return model.playlists.count + } + } + private var sidebar: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Source").font(.system(size: 15, weight: .semibold)).padding(.horizontal, 10).frame(height: 31) + .frame(maxWidth: .infinity, alignment: .leading).background(Classic.header) + ForEach([("Library", "music.note.house.fill", Color.yellow), ("Singers", "person.2.fill", Color.green), ("Venues", "building.2.fill", Color.cyan), ("Queue", "list.number", Color.orange), ("History", "clock.arrow.circlepath", Color.purple), ("Playlists", "music.note.list", Color.mint)], id: \.0) { item in + Button { source = item.0 } label: { + HStack(spacing: 10) { + Image(systemName: item.1).foregroundStyle(item.2).frame(width: 22) + Text(item.0).font(.system(size: 16, weight: .medium)) + Spacer() + Text("\(sourceCount(item.0))") + .font(.system(size: 11)).foregroundStyle(.white.opacity(0.6)) + }.padding(.horizontal, 12).frame(height: 34).contentShape(Rectangle()) + .background(source == item.0 ? Color(red: 0, green: 0.36, blue: 1) : .clear) + }.buttonStyle(.plain) + } + Spacer(minLength: 12) + SidebarVideoMonitor(player: model.player).padding(10) + }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) + } + private var songTable: some View { + GeometryReader { geometry in + ScrollView(.horizontal) { + VStack(spacing: 0) { + tableHeader(width: max(geometry.size.width, 790)) + ScrollView(.vertical) { + LazyVStack(spacing: 0) { + ForEach(visibleSongs) { song in songRow(song, width: max(geometry.size.width, 790)) } + }.frame(maxWidth: .infinity, alignment: .topLeading) + } + }.frame(width: max(geometry.size.width, 790), height: geometry.size.height) + } + }.background(Classic.navy).overlay(Rectangle().stroke(Classic.line, lineWidth: 1)) + } + private func widths(_ total: CGFloat) -> [CGFloat] { + [42, max(200, total - 598), 100, 100, 76, 86, 72, 122] + } + private func tableHeader(width: CGFloat) -> some View { + let sizes = widths(width) + return HStack(spacing: 0) { + ForEach(Array(["PLI", "Song Name", "Artist", "Album", "Label", "Key Adjust", "Tempo", "Rating"].enumerated()), id: \.offset) { index, title in + Text(title).font(.system(size: 13, weight: .medium)).lineLimit(1) + .padding(.leading, 7).frame(width: sizes[index], height: 30, alignment: .leading) + .overlay(alignment: .trailing) { Rectangle().fill(Classic.line).frame(width: 1).padding(.vertical, 4) } + } + }.foregroundStyle(.white).background(Classic.header) + .overlay(alignment: .bottom) { Rectangle().fill(Color(white: 0.65)).frame(height: 1) } + } + private func cellText(_ song: Song, column: Int) -> String { + switch column { + case 2: return song.artist.isEmpty ? "—" : song.artist + case 3: return song.album.isEmpty ? "—" : song.album + case 4: return song.label.isEmpty ? (song.cdg == nil ? "Audio" : URL(fileURLWithPath:song.audio).pathExtension.uppercased()+"+G") : song.label + case 5: return String(format:"%+.0f",model.preference(song.id).pitch) + default: return String(format:"%.0f%%",model.preference(song.id).tempo*100) + } + } + private func songRow(_ song: Song, width: CGFloat) -> some View { + let sizes = widths(width) + return HStack(spacing: 0) { + Image(systemName: song.cdg == nil ? "music.note" : "music.note.tv") + .foregroundStyle(.white.opacity(0.75)).frame(width: sizes[0]) + Text(song.title).lineLimit(1).padding(.leading, 7).frame(width: sizes[1], alignment: .leading) + ForEach(2..<7) { index in + Text(cellText(song, column: index)) + .foregroundStyle(.white.opacity(0.5)).padding(.leading, 7).frame(width: sizes[index], alignment: .leading) + } + Menu { + ForEach(0..<6) { value in + Button(value == 0 ? "Unrated" : String(repeating: "★", count: value)) { + model.perform { try model.library?.rate(song.id, value); try model.refresh() } + } + } + } label: { + Text(song.rating == 0 ? "☆☆☆☆☆" : String(repeating: "★", count: song.rating) + String(repeating: "☆", count: 5 - song.rating)) + .font(.system(size: 13)).foregroundStyle(song.rating == 0 ? Color.gray : Color.yellow) + }.menuStyle(.borderlessButton).frame(width: sizes[7] - 14).padding(.horizontal, 7).help("Set song rating") + }.font(.system(size: 13)).foregroundStyle(.white).frame(height: 30) + .background(selected == song.id ? Color(red: 0, green: 0.28, blue: 0.72) : .clear) + .contentShape(Rectangle()).onTapGesture(count: 2) { selected = song.id; playSelected() } + .onTapGesture { selected = song.id } + .contextMenu { + Button("Play") { selected = song.id; playSelected() } + Button("Add to Current Venue Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Menu("Add to Playlist") { ForEach(model.playlists) { playlist in Button(playlist.name) { model.addToPlaylist(playlist.id,song:song.id) } } } + } + } + @State private var managingRoster = false + @State private var rosterNewName = "" + @State private var editingEntity: PersonPlace? + @State private var renameText = "" + @State private var pendingDelete: PersonPlace? + private var directory: some View { + VStack(alignment:.leading,spacing:0) { + HStack { + Text(source == "Singers" ? "Singers · \(model.currentVenueName)" : "Venues").font(.headline) + Spacer() + if source == "Singers" { Button("Manage Venue Singers…") { managingRoster = true }.disabled(model.currentVenue == nil) } + Button(source == "Singers" ? "New Singer…" : "New Venue…") { model.creating = source == "Singers" ? .singer : .venue } + }.padding(12).background(Classic.header) + ScrollView { + VStack(alignment:.leading,spacing:0) { + if source == "Singers" && model.venueSingers.isEmpty { + Text("No singers at this venue. Add a singer or use Manage Venue Singers to include existing singers.").foregroundStyle(.secondary).padding() + } + ForEach(source == "Singers" ? model.venueSingers : model.venues) { entity in + HStack { + Button { + if source == "Singers" { model.selectedSinger = entity.id } else { model.chooseVenue(entity.id) } + } label: { + HStack { + Image(systemName:source == "Singers" ? "person.fill" : "building.2.fill") + Text(entity.name); Spacer() + if entity.id == (source == "Singers" ? model.selectedSinger : model.currentVenue) { + Text(source == "Singers" ? "Selected" : "Current venue").font(.caption).foregroundStyle(.cyan) + Image(systemName:"checkmark.circle.fill").foregroundStyle(.cyan) + } + }.contentShape(Rectangle()) + }.buttonStyle(.plain) + Button("Rename") { renameText = entity.name; editingEntity = entity } + Button("Delete",role:.destructive) { pendingDelete = entity } + }.padding(12) + } + } + } + HStack { + TextField(source == "Singers" ? "Singer name" : "Venue name",text:$name).textFieldStyle(.roundedBorder).onSubmit(addEntity) + Button(source == "Singers" ? "Add Singer" : "Add Venue",action:addEntity).disabled(name.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + }.padding(12) + }.foregroundStyle(.white).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + .sheet(isPresented:$managingRoster) { rosterSheet } + .sheet(item:$editingEntity) { entity in + VStack(alignment:.leading,spacing:16) { + Text("Rename \(source == "Singers" ? "Singer" : "Venue")").font(.headline) + TextField("Name",text:$renameText).textFieldStyle(.roundedBorder) + HStack { Button("Cancel") { editingEntity = nil }; Spacer(); Button("Save") { model.rename(source == "Singers" ? "singers" : "venues",id:entity.id,name:renameText); editingEntity = nil }.disabled(renameText.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) } + }.padding(24).frame(width:360) + } + .confirmationDialog("Delete \(pendingDelete?.name ?? "entry")? Historical plays are kept. Venue memberships and queued entries for it will be removed.",isPresented:Binding(get:{ pendingDelete != nil },set:{ if !$0 { pendingDelete = nil } })) { + Button("Delete",role:.destructive) { if let entity = pendingDelete { model.delete(source == "Singers" ? "singers" : "venues",id:entity.id) }; pendingDelete = nil } + } + } + private var rosterSheet: some View { + VStack(alignment:.leading,spacing:16) { + Text("Singers at \(model.currentVenueName)").font(.title2.bold()) + Text("A singer can belong to any number of venues. Unchecking removes them from this venue only.").foregroundStyle(.secondary) + ScrollView { + VStack(alignment:.leading,spacing:12) { + ForEach(model.singers) { singer in + Toggle(singer.name,isOn:Binding(get:{ model.venueSingers.contains { $0.id == singer.id } },set:{ model.membership(singer:singer.id,included:$0) })) + } + }.frame(maxWidth:.infinity,alignment:.leading) + }.frame(minHeight:120,maxHeight:280) + HStack { + TextField("New singer name",text:$rosterNewName).textFieldStyle(.roundedBorder) + Button("Add") { model.perform { _ = try model.createEntity(.singer,name:rosterNewName); rosterNewName = "" } }.disabled(rosterNewName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + Spacer(); Button("Done") { managingRoster = false }.keyboardShortcut(.defaultAction) + } + }.padding(24).frame(width:460) + } + private var detailPanel: some View { + VStack(alignment:.leading,spacing:9) { + HStack(spacing:10) { + Picker("Current venue",selection:Binding(get:{ model.currentVenue },set:{ model.chooseVenue($0) })) { + Text("Select venue…").tag(nil as Int64?) + ForEach(model.venues) { Text($0.name).tag(Optional($0.id)) } + } + Button { model.creating = .venue } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add venue") + Picker("Singer",selection:$model.selectedSinger) { + Text("None").tag(nil as Int64?) + ForEach(model.venueSingers) { Text($0.name).tag(Optional($0.id)) } + } + Button { model.creating = .singer } label: { Image(systemName:"plus.circle.fill") }.buttonStyle(.plain).help("Add singer") + Button("Queue Song") { if let selected { model.enqueue(selected) } }.disabled(selected == nil || model.currentVenue == nil) + } + PlaybackSettings(player:model.player,model:model,selectedSong:selected) + SeekBar(player:model.player) + }.padding(12).frame(maxWidth:.infinity,maxHeight:.infinity) + .background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + @State private var playlistName = "" + @State private var selectedPlaylist: Int64? + private var queuePane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { Text("Queue · \(model.currentVenueName)").font(.headline); Spacer(); Toggle("Auto play next",isOn:$model.autoAdvance).toggleStyle(.checkbox); Button("Play Next") { model.playQueueNext() }.disabled(model.queue.isEmpty) } + ScrollView { + VStack(spacing:0) { + ForEach(Array(model.queue.enumerated()),id:\.element.id) { index,row in + HStack { + Text("\(index+1)").foregroundStyle(.secondary).frame(width:24) + Text(row.singer).frame(width:140,alignment:.leading) + Text(row.title).lineLimit(1); Spacer() + Button { model.moveQueue(row.id,by:-1) } label: { Image(systemName:"arrow.up") }.disabled(index == 0) + Button { model.moveQueue(row.id,by:1) } label: { Image(systemName:"arrow.down") }.disabled(index == model.queue.count-1) + Button("Remove") { model.removeQueue(row.id) } + }.padding(.vertical,8) + } + if model.queue.isEmpty { Text("Choose a venue and singer, select a library song, then click Queue Song.").foregroundStyle(.secondary).padding(.top,30) } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + @State private var historyAllVenues = false + private var displayedHistory: [Performance] { + if historyAllVenues { return (try? model.library?.history(venue:nil,singer:model.selectedSinger)) ?? [] } + return model.history.filter { model.selectedSinger == nil || $0.singer == model.singers.first(where: { $0.id == model.selectedSinger })?.name } + } + private var historyPane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { Text("History · \(historyAllVenues || model.currentVenue == nil ? "All venues" : model.currentVenueName)").font(.headline); Spacer(); Toggle("All venues",isOn:$historyAllVenues).toggleStyle(.checkbox) } + ScrollView { + LazyVStack(alignment:.leading,spacing:12) { + ForEach(displayedHistory) { row in + HStack { Text(row.date).font(.system(.caption,design:.monospaced)); Text(row.singer).frame(width:130,alignment:.leading); Text(row.song); Spacer(); Text(row.venue).foregroundStyle(.secondary) } + } + } + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + private var playlistPane: some View { + VStack(alignment:.leading,spacing:12) { + HStack { + Picker("Playlist",selection:$selectedPlaylist) { Text("Choose…").tag(nil as Int64?); ForEach(model.playlists) { Text($0.name).tag(Optional($0.id)) } } + TextField("New playlist name",text:$playlistName).textFieldStyle(.roundedBorder) + Button("Create") { model.createPlaylist(playlistName); selectedPlaylist = model.playlists.first { $0.name == playlistName.trimmingCharacters(in:.whitespacesAndNewlines) }?.id; playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + if let selectedPlaylist { + Button("Rename") { model.rename("playlists",id:selectedPlaylist,name:playlistName); playlistName = "" }.disabled(playlistName.trimmingCharacters(in:.whitespacesAndNewlines).isEmpty) + Button("Delete",role:.destructive) { model.delete("playlists",id:selectedPlaylist); self.selectedPlaylist = nil } } + } + ScrollView { + if let selectedPlaylist { + let ids = (try? model.library?.playlistSongIDs(selectedPlaylist)) ?? [] + VStack(alignment:.leading,spacing:10) { + ForEach(ids,id:\.self) { id in + if let song = model.songs.first(where: { $0.id == id }) { + HStack { + Text(song.title); Spacer() + Button("Play") { model.perform { try model.play(song) } } + Button("Queue") { model.enqueue(song.id) }.disabled(model.currentVenue == nil) + Button("Remove") { model.perform { try model.library?.removeFromPlaylist(selectedPlaylist,song:id); try model.refresh() } } + } + } + } + } + } + Text("Add songs using the library row’s context menu.").foregroundStyle(.secondary).padding(.top,20) + } + }.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(Classic.navy).overlay(Rectangle().stroke(Classic.line)) + } + private var footer: some View { + HStack { + Button { importing = true } label: { Image(systemName: "plus").font(.system(size: 20, weight: .bold)).frame(width: 32, height: 24) } + .help("Import audio, MP3+G or ZIP").accessibilityLabel("Import songs") + RepeatButton(player: model.player).padding(.leading, 12) + Spacer() + Text("\(visibleSongs.count) \(visibleSongs.count == 1 ? "Song" : "Songs")").font(.system(size: 14, weight: .medium)) + Spacer() + Button { openWindow(id: "lyrics") } label: { Image(systemName: "rectangle.on.rectangle").frame(width: 32, height: 24) } + .help("Open video window").accessibilityLabel("Open video window") + }.buttonStyle(SilverSquareStyle()) + } + private func playSelected() { + guard let song = selectedSong else { return } + model.perform { try model.play(song) } + } + private func playNext() { + guard !visibleSongs.isEmpty else { return } + let index = visibleSongs.firstIndex { $0.id == selected } ?? -1 + selected = visibleSongs[(index + 1) % visibleSongs.count].id; playSelected() + } + private func addEntity() { + model.perform { + let kind: EntityKind = source == "Singers" ? .singer : .venue + let id = try model.createEntity(kind, name: name) + if kind == .singer { model.selectedSinger = id } else { model.chooseVenue(id) } + name = "" + } + } +} +private struct AddEntitySheet: View { + @ObservedObject var model: AppModel + let kind: EntityKind + let saved: (Int64) -> Void + @Environment(\.dismiss) private var dismiss + @State private var name = "" + @State private var error: String? + @FocusState private var focused: Bool + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("New \(kind.title)").font(.title2.bold()) + TextField("\(kind.title) name", text: $name).textFieldStyle(.roundedBorder) + .focused($focused).onSubmit(save) + if let error { Text(error).foregroundStyle(.red).font(.callout) } + HStack { + Spacer() + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Button("Add \(kind.title)", action: save).keyboardShortcut(.defaultAction) + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + }.padding(24).frame(width: 360).onAppear { focused = true } + } + private func save() { + do { let id = try model.createEntity(kind, name: name); saved(id); dismiss() } + catch { self.error = error.localizedDescription } + } +} +private struct TransportView: View { + @ObservedObject var player: Player + @Binding var search: String + let hasSelection: Bool + let play: () -> Void + let next: () -> Void + let display: () -> Void + var body: some View { + HStack(alignment: .top, spacing: 28) { + VStack(spacing: 15) { + HStack(spacing: 7) { + SilverButton(symbol: "backward.fill", label: "Back 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time - 10) } + SilverButton(symbol: player.playing ? "pause.fill" : "play.fill", label: player.playing ? "Pause" : "Play", size: 49, enabled: hasSelection || player.duration > 0) { + if player.duration > 0 { player.toggle() } else { play() } + } + SilverButton(symbol: "stop", label: "Stop", size: 46, enabled: player.duration > 0) { player.stop() } + SilverButton(symbol: "forward.fill", label: "Forward 10 seconds", size: 35, enabled: player.duration > 0) { player.seek(player.time + 10) } + SilverButton(symbol: "forward.end.fill", label: "Play next song", size: 26, enabled: hasSelection, action: next) + } + HStack(spacing: 9) { + Image(systemName: "speaker.fill").font(.system(size: 11)) + Slider(value: $player.volume, in: 0...1).controlSize(.small).tint(.gray).accessibilityLabel("Volume") + Image(systemName: "speaker.wave.3.fill").font(.system(size: 12)) + }.frame(width: 180) + }.frame(width: 220) + VStack(spacing: 0) { + Text(player.duration == 0 ? "Ready!" : player.title).font(.system(size: 15, weight: .medium)).lineLimit(1).padding(.horizontal, 20) + if player.duration > 0 { Text(player.playing ? "Playing" : "Paused").font(.system(size: 10)).foregroundStyle(.white.opacity(0.65)).padding(.top, 4) } + }.frame(maxWidth: .infinity).frame(height: 64) + .background(LinearGradient(colors: [Color(red: 0, green: 0.10, blue: 0.30), Classic.blue], startPoint: .top, endPoint: .bottom), in: Capsule()) + .overlay(Capsule().strokeBorder(LinearGradient(colors: [.black, .white], startPoint: .top, endPoint: .bottom), lineWidth: 1.5)) + .padding(.top, 14) + HStack(spacing: 12) { + TextField("Search", text: $search).textFieldStyle(.plain).font(.system(size: 16)) + .padding(.horizontal, 8).frame(width: 145, height: 28).background(Color(white: 0.07), in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).stroke(.white.opacity(0.1))) + SilverButton(symbol: "eye", label: "Open video window", size: 48, action: display) + }.padding(.top, 15) + }.foregroundStyle(.white).frame(height: 96) + } +} +private struct PlaybackSettings: View { + @ObservedObject var player: Player + @ObservedObject var model: AppModel + let selectedSong: Int64? + @State private var personalRating = 0 + var body: some View { + HStack(spacing:10) { + Text("Key") + Picker("Key",selection:$player.pitch) { ForEach(-12..<13) { Text(String(format:"%+d",$0)).tag(Double($0)) } }.labelsHidden().frame(width:70) + Text("Tempo") + Slider(value:$player.tempo,in:0.5...1.5,step:0.05).frame(width:85) + Text(String(format:"%.0f%%",player.tempo*100)).monospacedDigit().frame(width:40) + Text("Rating") + Picker("Singer rating",selection:$personalRating) { ForEach(0..<6) { Text($0 == 0 ? "Unrated" : String(repeating:"★",count:$0)).tag($0) } }.labelsHidden().frame(width:88).help("Singer-specific song rating") + Button("Save for Singer") { + if let song = player.currentSongID ?? selectedSong { model.savePreference(song:song,value:.init(pitch:player.pitch,tempo:player.tempo,rating:personalRating)) } + }.disabled(model.selectedSinger == nil || (player.currentSongID == nil && selectedSong == nil)) + Menu { + Button("Reset key / tempo") { player.pitch = 0; player.tempo = 1 } + ForEach([-1.0,-0.5,0,0.5,1.0],id:\.self) { offset in Button(String(format:"Graphics offset %+.1fs",offset)) { player.graphicsOffset = offset } } + } label: { Image(systemName:"slider.horizontal.3") }.menuStyle(.borderlessButton).frame(width:22).help("Playback settings") + }.font(.system(size:11)) + .onChange(of:model.selectedSinger) { _ in loadRating() } + .onChange(of:player.currentSongID) { _ in loadRating() } + .onChange(of:selectedSong) { _ in loadRating() } + } + private func loadRating() { if let song = player.currentSongID ?? selectedSong { personalRating = model.preference(song).rating } else { personalRating = 0 } } +} +private struct SeekBar: View { + @ObservedObject var player: Player + private func clock(_ seconds: Double) -> String { let n = Int(seconds); return String(format: "%d:%02d", n / 60, n % 60) } + var body: some View { + HStack(spacing: 10) { + Text(clock(player.time)) + Slider(value: Binding(get: { player.time }, set: { player.seek($0) }), in: 0...max(1, player.duration)) + .controlSize(.mini).tint(.gray).disabled(player.duration == 0).accessibilityLabel("Playback position") + Text(clock(player.duration)) + }.font(.system(size: 10, design: .monospaced)).foregroundStyle(.white.opacity(0.65)) + } +} +private struct RepeatButton: View { + @ObservedObject var player: Player + var body: some View { + Button { player.repeating.toggle() } label: { + Image(systemName: "repeat").font(.system(size: 18, weight: .semibold)) + .foregroundStyle(player.repeating ? Color.blue : Color(white: 0.25)).frame(width: 32, height: 24) + }.help(player.repeating ? "Repeat song: on" : "Repeat song: off").accessibilityLabel("Repeat song").accessibilityValue(player.repeating ? "On" : "Off") + } +} diff --git a/backups/before-waveform-20261103-082733/KJamsClone/Player.swift b/backups/before-waveform-20261103-082733/KJamsClone/Player.swift new file mode 100644 index 0000000..ab647b3 --- /dev/null +++ b/backups/before-waveform-20261103-082733/KJamsClone/Player.swift @@ -0,0 +1,91 @@ +import SwiftUI +import AVFoundation + +/// CDG follows consumed source frames, so tempo adjustment preserves graphics timing. +@MainActor final class Player: ObservableObject { + @Published var image: CGImage? + @Published var playing = false + @Published var time: Double = 0 + @Published var duration: Double = 0 + @Published var title = "Select a song" + @Published var volume: Double = 0.8 { didSet { engine.mainMixerNode.outputVolume = Float(volume) } } + @Published var repeating = false + @Published var pitch: Double = 0 { didSet { effect.pitch = Float(pitch * 100) } } + @Published var tempo: Double = 1 { didSet { effect.rate = Float(tempo) } } + @Published var graphicsOffset: Double = 0 + private(set) var currentSongID: Int64? + var onFinished: (() -> Void)? + private let engine = AVAudioEngine() + private let node = AVAudioPlayerNode() + private let effect = AVAudioUnitTimePitch() + private var file: AVAudioFile? + private var anchor: Double = 0 + private var generation = UUID() + private var cdg = Data() + private var decoder = CDGDecoder() + private var timer: Timer? + init() { engine.attach(node); engine.attach(effect) } + func load(_ song: Song, preference: SongPreference = .init()) throws { + stop() + do { + let loaded = try AVAudioFile(forReading:URL(fileURLWithPath:song.audio)) + let graphics = try song.cdg.map { try Data(contentsOf:URL(fileURLWithPath:$0)) } ?? Data() + guard loaded.length > 0 else { throw LibraryError.message("Audio file is empty") } + engine.connect(node,to:effect,format:loaded.processingFormat) + engine.connect(effect,to:engine.mainMixerNode,format:loaded.processingFormat) + file = loaded; cdg = graphics; decoder = CDGDecoder() + duration = Double(loaded.length)/loaded.processingFormat.sampleRate + title = song.title; currentSongID = song.id + pitch = preference.pitch; tempo = preference.tempo + engine.mainMixerNode.outputVolume = Float(volume) + engine.prepare(); try engine.start() + schedule(at:0,play:true); startTimer(); tick() + } catch { stop(); throw error } + } + private func schedule(at seconds: Double, play: Bool) { + guard let file else { return } + generation = UUID(); let token = generation + node.stop() + let rate = file.processingFormat.sampleRate + let start = min(file.length,max(0,AVAudioFramePosition(seconds*rate))) + anchor = Double(start)/rate; time = anchor + guard start < file.length else { playing = false; render(); return } + node.scheduleSegment(file,startingFrame:start,frameCount:AVAudioFrameCount(min(file.length-start,Int64(UInt32.max))),at:nil,completionCallbackType:.dataPlayedBack) { [weak self] _ in + Task { @MainActor in + guard let self, self.generation == token else { return } + if self.repeating { self.schedule(at:0,play:true) } + else { + self.playing = false; self.time = self.duration; self.render() + self.timer?.invalidate(); self.timer = nil; self.onFinished?() + } + } + } + if play { node.play() }; playing = play; render() + } + private func startTimer() { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval:1.0/30,repeats:true) { [weak self] _ in Task { @MainActor in self?.tick() } } + } + func toggle() { + guard file != nil else { return } + if playing { tick(); node.pause(); playing = false } + else if time >= duration { schedule(at:0,play:true); startTimer() } + else { node.play(); playing = true; startTimer() } + } + func stop() { + generation = UUID(); timer?.invalidate(); timer = nil; node.stop(); engine.stop() + engine.disconnectNodeOutput(node); engine.disconnectNodeOutput(effect) + file = nil; playing = false; time = 0; anchor = 0; duration = 0; image = nil; currentSongID = nil; title = "Select a song" + } + func seek(_ value: Double) { + guard file != nil, value.isFinite else { return } + schedule(at:min(max(value,0),duration),play:playing); startTimer() + } + private func render() { decoder.render(cdg,at:max(0,time+graphicsOffset)); image = cdg.isEmpty ? nil : decoder.image() } + private func tick() { + if playing, let file, let renderTime = node.lastRenderTime, let playerTime = node.playerTime(forNodeTime:renderTime) { + time = min(duration,max(0,anchor+Double(playerTime.sampleTime)/file.processingFormat.sampleRate)) + } + render() + } +} diff --git a/backups/before-waveform-20261103-082733/KJamsCloneTests/KJamsCloneTests.swift b/backups/before-waveform-20261103-082733/KJamsCloneTests/KJamsCloneTests.swift new file mode 100644 index 0000000..69948bc --- /dev/null +++ b/backups/before-waveform-20261103-082733/KJamsCloneTests/KJamsCloneTests.swift @@ -0,0 +1,229 @@ +import XCTest +@testable import KJamsClone + +final class KJamsCloneTests: XCTestCase { + func packet(_ instruction: UInt8, _ data: [UInt8]) -> [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] } + func testPresetTileXORAndInvalidCoordinates() { + var d = CDGDecoder() + d.apply(packet(1,[3,0])); XCTAssertEqual(d.pixels[0],3) + d.apply(packet(6,[0,5,1,1] + Array(repeating:63,count:12))) + XCTAssertEqual(d.pixels[12*300+6],5) + d.apply(packet(38,[0,5,1,1] + Array(repeating:63,count:12))) + XCTAssertEqual(d.pixels[12*300+6],0) + let old = d.pixels; d.apply(packet(6,[1,2,31,63])); XCTAssertEqual(d.pixels,old) + } + func testSeekAndTruncatedPacket() { + var d = CDGDecoder() + let data = Data(packet(1,[2,0]) + packet(1,[7,0]) + [9]) + d.render(data,at:1); XCTAssertEqual(d.pixels[0],7); XCTAssertEqual(d.packetIndex,2) + d.render(data,at:1.0/300); XCTAssertEqual(d.pixels[0],2) + d.render(data,at:0); XCTAssertEqual(d.pixels[0],0) + } + func testPaletteBorderScrollAndImage() { + var d = CDGDecoder() + d.apply(packet(30,[63,63])); XCTAssertEqual(d.palette[0],0xFFFFFFFF) + d.apply(packet(2,[4])); XCTAssertEqual(d.pixels[0],4); XCTAssertEqual(d.pixels[12*300+6],0) + d.apply(packet(20,[3,16,0])); XCTAssertEqual(d.pixels[100*300],3) + d.apply(packet(24,[0,32,0])); XCTAssertEqual(d.pixels[100*300+294],3) + XCTAssertEqual(d.image()?.width,288) + } + func testSQLitePersistenceDedupRatingsAndHistory() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let url = folder.appendingPathComponent("test.sqlite") + do { + let db = try Library(url:url) + try db.add(title:"Singer's Song",audio:"/test.mp3",cdg:nil) + try db.add(title:"Duplicate",audio:"/test.mp3",cdg:"/test.cdg") + let song = try XCTUnwrap(db.songs().first) + try db.rate(song.id,5); XCTAssertThrowsError(try db.rate(song.id,6)) + try db.addEntity("singers",name:"O'Neil"); try db.addEntity("venues",name:"Hall") + try db.record(song.id,singer:db.entities("singers").first?.id,venue:db.entities("venues").first?.id) + } + let reopened = try Library(url:url); let songs = try reopened.songs() + XCTAssertEqual(songs.count,1); XCTAssertEqual(songs[0].rating,5); XCTAssertEqual(songs[0].plays,1); XCTAssertEqual(songs[0].cdg,"/test.cdg") + XCTAssertEqual(try reopened.entities("singers").first?.name,"O'Neil") + } + func testPairedImportAndContentDedup() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let audio = root.appendingPathComponent("Test.mp3"), cdg = root.appendingPathComponent("Test.CDG") + try Data([1,2,3]).write(to:audio); try Data(packet(1,[2,0])).write(to:cdg) + let db = try Library(url:root.appendingPathComponent("test.sqlite")) + let importer = Importer(media:root.appendingPathComponent("Media")) + try importer.run([audio],library:db); try importer.run([audio],library:db) + XCTAssertEqual(try db.songs().count,1) + XCTAssertNotNil(try db.songs().first?.cdg) + } + + func testZippedMP3GImportAndUnsafeArchiveRejection() throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:folder) } + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let db = try Library(url:folder.appendingPathComponent("db.sqlite")) + let importer = Importer(media:folder.appendingPathComponent("Media")) + try importer.run([fixtures.appendingPathComponent("Demo.zip")],library:db) + let song = try XCTUnwrap(db.songs().first) + XCTAssertEqual(song.title,"Demo"); XCTAssertNotNil(song.cdg) + XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio)) + XCTAssertThrowsError(try importer.run([fixtures.appendingPathComponent("Unsafe.zip")],library:db)) + } + @MainActor func testMP3PlaybackPauseAndSeek() throws { + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let player = Player() + defer { player.stop() } + try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0)) + XCTAssertGreaterThan(player.duration,3.9); XCTAssertTrue(player.playing) + player.toggle(); XCTAssertFalse(player.playing) + player.seek(2); XCTAssertEqual(player.time,2,accuracy:0.05); XCTAssertNotNil(player.image) + player.seek(0.1); XCTAssertEqual(player.time,0.1,accuracy:0.05) + } + @MainActor func testCreateSingerAndVenueThroughAppModel() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let model = AppModel(storageURL: root) + XCTAssertNil(model.error) + let singer = try model.createEntity(.singer, name: " Alex ") + let venue = try model.createEntity(.venue, name: "Town Hall") + XCTAssertEqual(model.singers.first?.name, "Alex") + XCTAssertEqual(model.venues.first?.name, "Town Hall") + XCTAssertEqual(try model.createEntity(.singer, name: "Alex"), singer) + XCTAssertEqual(model.singers.count, 1) + XCTAssertThrowsError(try model.createEntity(.venue, name: " ")) + let reopened = AppModel(storageURL: root) + XCTAssertEqual(reopened.singers.first?.id, singer) + XCTAssertEqual(reopened.venues.first?.id, venue) + } + + @MainActor func testVenuesShareSingersAndRememberSelection() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root) + let hall = try model.createEntity(.venue,name:"Hall") + let alex = try model.createEntity(.singer,name:"Alex") + let club = try model.createEntity(.venue,name:"Club") + XCTAssertTrue(model.venueSingers.isEmpty) + model.membership(singer:alex,included:true) + XCTAssertEqual(model.venueSingers.map(\.id),[alex]) + model.chooseVenue(hall); XCTAssertEqual(model.venueSingers.map(\.id),[alex]) + model.chooseVenue(club); model.membership(singer:alex,included:false) + XCTAssertTrue(model.venueSingers.isEmpty) + let reopened = AppModel(storageURL:root) + XCTAssertEqual(reopened.currentVenue,club); XCTAssertTrue(reopened.venueSingers.isEmpty) + reopened.chooseVenue(hall); XCTAssertEqual(reopened.venueSingers.map(\.id),[alex]) + XCTAssertEqual(reopened.singers.count,1) + } + func testMigrationAndMembershipRemovalDoesNotReappear() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let url = root.appendingPathComponent("db.sqlite") + var singer: Int64 = 0, venue: Int64 = 0 + do { + let db = try Library(url:url) + try db.add(title:"Song",audio:"/a.mp3",cdg:nil) + try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall") + singer = try XCTUnwrap(db.entities("singers").first?.id); venue = try XCTUnwrap(db.entities("venues").first?.id) + try db.record(XCTUnwrap(db.songs().first?.id),singer:singer,venue:venue) + try db.execute("PRAGMA user_version=1") + } + do { + let migrated = try Library(url:url) + XCTAssertEqual(try migrated.members(of:venue).map(\.id),[singer]) + try migrated.setMembership(singer:singer,venue:venue,included:false) + } + let reopened = try Library(url:url) + XCTAssertTrue(try reopened.members(of:venue).isEmpty) + XCTAssertEqual(try reopened.history(venue:venue).count,1) + } + func testQueuePlaylistsPreferencesAndDeletion() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let db = try Library(url:root.appendingPathComponent("db.sqlite")) + try db.add(title:"First",audio:"/1.mp3",cdg:nil) + try db.add(title:"Second",audio:"/2.mp3",cdg:nil) + let songs = try db.songs() + try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall"); try db.addEntity("venues",name:"Club") + let singer = try XCTUnwrap(db.entities("singers").first?.id) + let hall = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Hall" })?.id) + let club = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Club" })?.id) + XCTAssertThrowsError(try db.enqueue(song:songs[0].id,singer:singer,venue:hall)) + try db.setMembership(singer:singer,venue:hall,included:true) + try db.enqueue(song:songs[0].id,singer:singer,venue:hall); try db.enqueue(song:songs[1].id,singer:singer,venue:hall) + XCTAssertTrue(try db.queue(venue:club).isEmpty) + let second = try XCTUnwrap(db.queue(venue:hall).last) + try db.moveQueue(second.id,venue:hall,by:-1) + XCTAssertEqual(try db.queue(venue:hall).first?.songID,songs[1].id) + let preference = SongPreference(pitch:3,tempo:0.85,rating:4) + try db.savePreference(song:songs[0].id,singer:singer,value:preference) + XCTAssertEqual(try db.preference(song:songs[0].id,singer:singer),preference) + XCTAssertThrowsError(try db.savePreference(song:songs[0].id,singer:singer,value:.init(pitch:99))) + try db.addEntity("playlists",name:"Party") + let playlist = try XCTUnwrap(db.entities("playlists").first?.id) + try db.addToPlaylist(playlist,song:songs[0].id); try db.addToPlaylist(playlist,song:songs[0].id) + XCTAssertEqual(try db.playlistSongIDs(playlist),[songs[0].id]) + try db.record(songs[0].id,singer:singer,venue:hall) + try db.rename("singers",id:singer,name:"Alex Jones") + XCTAssertEqual(try db.history(venue:hall).first?.singer,"Alex Jones") + try db.deleteEntity("singers",id:singer) + XCTAssertTrue(try db.queue(venue:hall).isEmpty) + XCTAssertEqual(try db.history(venue:hall).count,1) + try db.selectVenue(hall); try db.deleteEntity("venues",id:hall) + XCTAssertNil(try db.currentVenue()); XCTAssertEqual(try db.history(venue:nil).count,1) + } + @MainActor func testTempoSourceClockPauseAndResume() async throws { + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let player = Player(); defer { player.stop() } + let song = Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0) + try player.load(song,preference:.init(pitch:2,tempo:1.5,rating:3)) + XCTAssertEqual(player.pitch,2); XCTAssertEqual(player.tempo,1.5) + try await Task.sleep(nanoseconds:500_000_000) + XCTAssertGreaterThan(player.time,0.45); XCTAssertLessThan(player.time,1.3) + player.toggle(); let paused = player.time + try await Task.sleep(nanoseconds:100_000_000) + XCTAssertEqual(player.time,paused,accuracy:0.025) + player.seek(1.5); player.toggle() + try await Task.sleep(nanoseconds:200_000_000) + XCTAssertGreaterThan(player.time,1.6); XCTAssertNotNil(player.image) + } + func testNewTestMediaMetadataAndCDGPairImport() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true) + defer { try? FileManager.default.removeItem(at:root) } + let db = try Library(url:root.appendingPathComponent("db.sqlite")) + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + let importer = Importer(media:root.appendingPathComponent("Media")) + try importer.run([fixtures.appendingPathComponent("TestTrack.cdg")],library:db) + let song = try XCTUnwrap(db.songs().first) + XCTAssertEqual(song.title,"MP3 Plus G Test") + XCTAssertEqual(song.artist,"KJamsClone Test Generator") + XCTAssertNotNil(song.cdg) + let bytes = try Data(contentsOf:fixtures.appendingPathComponent("TestTrack.cdg")) + XCTAssertEqual(bytes.count,20*300*24) + var decoder = CDGDecoder(); decoder.render(bytes,at:2) + XCTAssertTrue(decoder.pixels.contains(1)); XCTAssertTrue(decoder.pixels.contains(2)) + } + @MainActor func testAutomaticVenueQueueAdvancesAndRecordsSinger() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at:root) } + let model = AppModel(storageURL:root); defer { model.player.stop() } + _ = try model.createEntity(.venue,name:"Test Hall") + _ = try model.createEntity(.singer,name:"Alex") + let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures")) + model.importFiles([fixtures.appendingPathComponent("Demo.zip")]) + let song = try XCTUnwrap(model.songs.first) + model.enqueue(song.id); model.enqueue(song.id); model.playQueueNext() + XCTAssertEqual(model.queue.count,1); XCTAssertEqual(model.history.count,1) + model.player.seek(model.player.duration-0.1) + for _ in 0..<25 { + if model.queue.isEmpty { break } + try await Task.sleep(nanoseconds:100_000_000) + } + XCTAssertNil(model.error); XCTAssertTrue(model.queue.isEmpty) + XCTAssertEqual(model.history.count,2); XCTAssertEqual(model.history.first?.singer,"Alex") + } +} diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md new file mode 100644 index 0000000..ab412a5 --- /dev/null +++ b/docs/VERIFICATION.md @@ -0,0 +1,13 @@ +# KJMix Pro 0.5.0 — verified 3 November 2026 + +- Real shared macOS Xcode project/scheme, SwiftUI app, SQLite migration to schema 9 and XCTest target. Existing bundle/module/data identity retained. +- **51 XCTest cases passed, zero failures, TEST SUCCEEDED.** Added tests cover cyclic singer interleave/empty singers/departed cursor, per-venue roster/settings and individual pitch/tempo/rating persistence, atomic batch edits, saved playlist order, targeted/quoted/exact search, persisted 1920×1080 ShowScreens with pause/seek/end callback, timed/manual fades without changing global mixing, MP3 ID3 changes with byte-identical original backup and unchanged decoded audio, MP4 passthrough tags retaining audio/video, authenticated singer API isolation/revocation/venue scope, parser bounds and duplicate/chunked/pipeline rejection, a real NWListener/URLSession loopback server, all four store profiles and catalogue validation. Earlier 39 regressions remain passing. +- Live public Apple metadata request returned three usable title/artist candidates. No account login, purchase or media download occurred. App lookup is user-initiated and reviewed before applying. +- Release succeeded for arm64/x86_64, version 0.5.0 build 10, CFBundleName KJMix Pro, complete AppIcon.icns/Assets.car. Tested on Apple Silicon macOS 27.2 with Xcode 27.2/Swift 6.4.2; Intel/older macOS execution not validated. +- Actual SwiftUI content captured at standard/minimum main window sizes and new rotation, ShowScreens, singer server and batch metadata panes, plus the shared rendered slide. Temporary original demo data only. Offscreen content captures omit the system titlebar/toolbar. Native pointer/touch/keyboard end-to-end control and external display/audio latency are not certified. +- Singer HTML JavaScript syntax checked with JavaScriptCore. An actual WKWebView loaded the local NWListener page with a temporary invitation, authenticated as the demo singer and rendered the Tonight list at 430×850; snapshot inspected. Browser keyboard/touch flows are not exhaustively automated. +- Singer server remains off by default. Real local HTTP catalogue/page/unauthorised requests tested; browser/device/firewall/network configurations and sustained concurrent-show use are not exhaustively validated. No public TLS/account hosting, QR code or kJams Cue compatibility is claimed. +- Store profiles and imported catalogue/browser workflows exist. **Direct store purchasing, subscription streaming, encrypted offline cache and previous-purchase sync are NOT implemented or verified.** Those require partner integration documentation/SDKs and application credentials. This is not full Music Store reference parity. +- Source archive checks: CRC, expected project.pbxproj/shared scheme and implementation files, executable build/test/release scripts, SHA-256 manifest, byte-identical extraction, extracted Xcode project/scheme recognition. App archive checks: CRC, product/version/bundle identifier, icon resources and both architectures. Only whitelisted original fixtures are packaged; no personal songs, databases, invitation tokens, imported real catalogues, credentials, waveform caches or backups are included. + +See README.md for workflows, exact tag/search/media support and remaining limitations. Unsigned and unnotarised release. Full kJams parity is not claimed. diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..e44b4bd --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." +xcodebuild -project "KJMix Pro.xcodeproj" -scheme "KJMix Pro" -configuration Debug -destination 'platform=macOS' -derivedDataPath "${DERIVED_DATA:-$PWD/build}" CODE_SIGNING_ALLOWED=NO build "$@" diff --git a/scripts/generate-test-media.py b/scripts/generate-test-media.py new file mode 100644 index 0000000..f6b74ee --- /dev/null +++ b/scripts/generate-test-media.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Original synthetic MP3+G test media. Regeneration requires ffmpeg; playback does not.""" +from pathlib import Path +import wave,struct,math,subprocess,zipfile,shutil +root=Path(__file__).resolve().parents[1]/'Fixtures';root.mkdir(exist_ok=True) +font={ +'A':['01110','10001','10001','11111','10001','10001','10001'], +'C':['01111','10000','10000','10000','10000','10000','01111'], +'D':['11110','10001','10001','10001','10001','10001','11110'], +'E':['11111','10000','10000','11110','10000','10000','11111'], +'F':['11111','10000','10000','11110','10000','10000','10000'], +'G':['01111','10000','10000','10111','10001','10001','01111'], +'H':['10001','10001','10001','11111','10001','10001','10001'], +'I':['11111','00100','00100','00100','00100','00100','11111'], +'J':['00111','00010','00010','00010','10010','10010','01100'], +'K':['10001','10010','10100','11000','10100','10010','10001'], +'L':['10000','10000','10000','10000','10000','10000','11111'], +'M':['10001','11011','10101','10101','10001','10001','10001'], +'N':['10001','11001','10101','10011','10001','10001','10001'], +'O':['01110','10001','10001','10001','10001','10001','01110'], +'P':['11110','10001','10001','11110','10000','10000','10000'], +'R':['11110','10001','10001','11110','10100','10010','10001'], +'S':['01111','10000','10000','01110','00001','00001','11110'], +'T':['11111','00100','00100','00100','00100','00100','00100'], +'U':['10001','10001','10001','10001','10001','10001','01110'], +'V':['10001','10001','10001','10001','10001','01010','00100'], +'W':['10001','10001','10001','10101','10101','11011','10001'], +'Y':['10001','10001','01010','00100','00100','00100','00100'], +'3':['11110','00001','00001','01110','00001','00001','11110'], +' ':['00000']*7, +} +def packet(inst,data):return bytes([9,inst,0,0]+(data+[0]*16)[:16]+[0]*4) +packets=[bytes(24) for _ in range(6000)] +def put(index,seq): + for p in seq: + assert packets[index]==bytes(24),'overlapping instructions' + packets[index]=p;index+=1 + return index +colors=[0x013,0xfff,0xfd0,0x0cf]+[0]*12 +def palette(offset): + data=[] + for c in colors[offset:offset+8]:data.extend([(c>>6)&63,c&63]) + return packet(30 if offset==0 else 31,data) +put(0,[palette(0),palette(8),packet(1,[0,0]),packet(2,[0])]) +def frame(lines): + pixels=[[0]*300 for _ in range(216)] + for line,(text,y) in enumerate(lines): + x=(300-len(text)*12)//2 + for char in text: + for gy,row in enumerate(font[char]): + for gx,bit in enumerate(row): + if bit=='1': + for dy in range(2): + for dx in range(2):pixels[y+gy*2+dy][x+gx*2+dx]=1 + x+=12 + return pixels +def tiles(pixels,xor=False): + result=[] + for row in range(1,17): + for col in range(1,49): + bits=[] + for y in range(12): + v=0 + for x in range(6):v=(v<<1)|pixels[row*12+y][col*6+x] + bits.append(v) + if any(bits):result.append(packet(38 if xor else 6,[0,3 if xor else 1,row,col]+bits)) + return result +scenes=[[("KJAMS CLONE",66),("MP3 PLUS G TEST",114)],[("ONE TWO THREE FOUR",66),("SING ALONG",114)],[("WITH THE TEST TONE",66),("PLAY PAUSE SEEK",114)],[("MP3 PLUS G TEST",66),("TEST COMPLETE",114)]] +for index,lines in enumerate(scenes): + start=index*1500+(5 if index==0 else 0) + pixels=frame(lines) + put(start,([packet(1,[0,0])] if index else [])+tiles(pixels)) + # Highlight each word after the initial white text has been drawn. + for line,(text,y) in enumerate(lines): + left=(300-len(text)*12)//2 + words=text.split();cursor=0 + for wi,word in enumerate(words): + mask=[[0]*300 for _ in range(216)] + for py in range(y,y+14): + for px in range(left+cursor*12,left+(cursor+len(word))*12):mask[py][px]=pixels[py][px] + put(index*1500+240+line*600+wi*100,tiles(mask,True)) + cursor+=len(word)+1 +(root/'TestTrack.cdg').write_bytes(b''.join(packets)) +rate=44100;duration=20;notes=[261.63,329.63,392.0,523.25,392.0,329.63,293.66,261.63] +with wave.open(str(root/'TestTrack.wav'),'w') as out: + out.setparams((1,2,rate,0,'NONE','not compressed')) + for beat in range(duration*2): + freq=notes[beat%len(notes)] + data=bytearray() + for i in range(rate//2): + t=i/rate;fade=min(1,t/0.015,(0.5-t)/0.04) + data.extend(struct.pack(' +SwiftCompile normal x86_64 Compiling\ DJWorkspace.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/DJWorkspace.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftCompile normal x86_64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/DJWorkspace.swift (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-SwiftPerFileCompile DJWorkspace.swift + +SwiftCompile normal x86_64 Compiling\ KJMixProApp.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/KJMixProApp.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftCompile normal x86_64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/KJMixProApp.swift (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-SwiftPerFileCompile KJMixProApp.swift + +SwiftEmitModule normal arm64 Emitting\ module\ for\ KJamsClone (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftCompile normal arm64 (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + + +SwiftCompile normal arm64 Compiling\ KJMixProApp.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/KJMixProApp.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftCompile normal arm64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/KJMixProApp.swift (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-SwiftPerFileCompile KJMixProApp.swift + +SwiftCompile normal arm64 Compiling\ DJWorkspace.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/DJWorkspace.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftCompile normal arm64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/DJWorkspace.swift (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-SwiftPerFileCompile DJWorkspace.swift + +SwiftCompile normal arm64 Compiling\ Player.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Player.swift (in target 'KJMix Pro' from project 'KJMix Pro') +/Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Player.swift:301:35: warning: reference to captured var 'self' in concurrently-executing code; this is an error in the Swift 6 language mode [#SendableClosureCaptures] +299 | guard !Task.isCancelled else { return };let frame = decoded.image(),finished = decoded +300 | await MainActor.run { +301 | guard let self,self.graphicsGeneration == token else { return } + | `- warning: reference to captured var 'self' in concurrently-executing code; this is an error in the Swift 6 language mode [#SendableClosureCaptures] +302 | self.decoder = finished;self.lastGraphicsPacket = target;self.image = frame;self.graphicsTask = nil +303 | } + +[#SendableClosureCaptures]: + +SwiftCompile normal arm64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Player.swift (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-SwiftPerFileCompile Player.swift + +SwiftDriverJobDiscovery normal arm64 Compiling Player.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftCompile normal arm64 Compiling\ Metadata.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Metadata.swift (in target 'KJMix Pro' from project 'KJMix Pro') +/Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Metadata.swift:97:31: warning: 'metadata' was deprecated in macOS 13.0: Use load(.metadata) instead [#DeprecatedDeclaration] + 95 | exporter.outputURL = temp;exporter.outputFileType = type + 96 | let keys: [AVMetadataKey] = [.commonKeyTitle,.commonKeyArtist,.commonKeyAlbumName,.commonKeyPublisher] + 97 | var items = asset.metadata.filter{!keys.contains($0.commonKey ?? AVMetadataKey(rawValue:""))} + | `- warning: 'metadata' was deprecated in macOS 13.0: Use load(.metadata) instead [#DeprecatedDeclaration] + 98 | for (identifier,value) in [(AVMetadataIdentifier.commonIdentifierTitle,song.title),(.commonIdentifierArtist,song.artist),(.commonIdentifierAlbumName,song.album),(.commonIdentifierPublisher,song.label)] { + 99 | let item = AVMutableMetadataItem();item.identifier = identifier;item.value = value as NSString;items.append(item) + +[#DeprecatedDeclaration]: + +SwiftCompile normal arm64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Metadata.swift (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-SwiftPerFileCompile Metadata.swift + +SwiftCompile normal arm64 Compiling\ SingerServer.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/SingerServer.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftCompile normal arm64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/SingerServer.swift (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-SwiftPerFileCompile SingerServer.swift + +SwiftCompile normal arm64 Compiling\ Hosting.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Hosting.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftCompile normal arm64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Hosting.swift (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-SwiftPerFileCompile Hosting.swift + +SwiftDriverJobDiscovery normal x86_64 Compiling Player.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftCompile normal x86_64 Compiling\ Metadata.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Metadata.swift (in target 'KJMix Pro' from project 'KJMix Pro') +/Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Metadata.swift:97:31: warning: 'metadata' was deprecated in macOS 13.0: Use load(.metadata) instead [#DeprecatedDeclaration] + 95 | exporter.outputURL = temp;exporter.outputFileType = type + 96 | let keys: [AVMetadataKey] = [.commonKeyTitle,.commonKeyArtist,.commonKeyAlbumName,.commonKeyPublisher] + 97 | var items = asset.metadata.filter{!keys.contains($0.commonKey ?? AVMetadataKey(rawValue:""))} + | `- warning: 'metadata' was deprecated in macOS 13.0: Use load(.metadata) instead [#DeprecatedDeclaration] + 98 | for (identifier,value) in [(AVMetadataIdentifier.commonIdentifierTitle,song.title),(.commonIdentifierArtist,song.artist),(.commonIdentifierAlbumName,song.album),(.commonIdentifierPublisher,song.label)] { + 99 | let item = AVMutableMetadataItem();item.identifier = identifier;item.value = value as NSString;items.append(item) + +[#DeprecatedDeclaration]: + +SwiftCompile normal x86_64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Metadata.swift (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-SwiftPerFileCompile Metadata.swift + +SwiftCompile normal x86_64 Compiling\ Hosting.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Hosting.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftCompile normal x86_64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/Hosting.swift (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-SwiftPerFileCompile Hosting.swift + +SwiftCompile normal x86_64 Compiling\ SingerServer.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/SingerServer.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftCompile normal x86_64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsClone/SingerServer.swift (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-SwiftPerFileCompile SingerServer.swift + +SwiftDriverJobDiscovery normal arm64 Compiling SingerServer.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftDriverJobDiscovery normal x86_64 Compiling SingerServer.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftDriverJobDiscovery normal arm64 Compiling Hosting.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftDriverJobDiscovery normal x86_64 Compiling Hosting.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftDriverJobDiscovery normal x86_64 Compiling DJWorkspace.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftDriverJobDiscovery normal arm64 Compiling DJWorkspace.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftDriverJobDiscovery normal arm64 Compiling Metadata.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftDriverJobDiscovery normal x86_64 Compiling Metadata.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftDriverJobDiscovery normal x86_64 Compiling KJMixProApp.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftDriver\ Compilation KJMix\ Pro normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-Swift-Compilation -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -O @/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -Xfrontend -serialize-debugging-options -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -F /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo + +SwiftDriverJobDiscovery normal arm64 Emitting module for KJamsClone (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftDriver\ Compilation\ Requirements KJMix\ Pro normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-Swift-Compilation-Requirements -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -O @/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -Xfrontend -serialize-debugging-options -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -F /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo + +SwiftDriverJobDiscovery normal x86_64 Emitting module for KJamsClone (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftDriver\ Compilation\ Requirements KJMix\ Pro normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-Swift-Compilation-Requirements -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -O @/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -Xfrontend -serialize-debugging-options -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -F /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo + +Ld /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/Binary/KJMix\ Pro normal x86_64 (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target x86_64-apple-macos13.0 -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -Os -Xlinker -no_warn_duplicate_libraries -L/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/EagerLinkingTBDs/Release -L/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -F/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/EagerLinkingTBDs/Release -F/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -filelist /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro.LinkFileList -Xlinker -rpath -Xlinker /usr/lib/swift -Xlinker -object_path_lto -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro_lto.o -Xlinker -dependency_info -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro_dependency_info.dat -fobjc-link-runtime -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx -L/usr/lib/swift -Xlinker -add_ast_path -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftmodule -lsqlite3 -o /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/Binary/KJMix\ Pro + +SwiftDriverJobDiscovery normal arm64 Compiling KJMixProApp.swift (in target 'KJMix Pro' from project 'KJMix Pro') + +SwiftDriver\ Compilation KJMix\ Pro normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-Swift-Compilation -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJamsClone -O @/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro.SwiftFileList -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -Xfrontend -serialize-debugging-options -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -F /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo + +Ld /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/Binary/KJMix\ Pro normal arm64 (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target arm64-apple-macos13.0 -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -Os -Xlinker -no_warn_duplicate_libraries -L/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/EagerLinkingTBDs/Release -L/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -F/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/EagerLinkingTBDs/Release -F/Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release -filelist /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro.LinkFileList -Xlinker -rpath -Xlinker /usr/lib/swift -Xlinker -object_path_lto -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro_lto.o -Xlinker -dependency_info -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro_dependency_info.dat -fobjc-link-runtime -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx -L/usr/lib/swift -Xlinker -add_ast_path -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftmodule -lsqlite3 -o /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/Binary/KJMix\ Pro + +Copy /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/x86_64-apple-macos.swiftmodule /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftmodule (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftmodule /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/x86_64-apple-macos.swiftmodule + +Copy /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/x86_64-apple-macos.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.abi.json (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/x86_64-apple-macos.abi.json + +Copy /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/arm64-apple-macos.swiftmodule /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftmodule (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftmodule /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/arm64-apple-macos.swiftmodule + +Copy /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/arm64-apple-macos.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.abi.json (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/arm64-apple-macos.abi.json + +Copy /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/Project/x86_64-apple-macos.swiftsourceinfo /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftsourceinfo (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJamsClone.swiftsourceinfo /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/Project/x86_64-apple-macos.swiftsourceinfo + +Copy /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftsourceinfo (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJamsClone.swiftsourceinfo /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJamsClone.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo + +CreateUniversalBinary /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro normal arm64\ x86_64 (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/lipo -create /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/Binary/KJMix\ Pro /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/Binary/KJMix\ Pro -output /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro + +ExtractAppIntentsMetadata (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/appintentsmetadataprocessor --toolchain-dir /var/run/com.apple.security.cryptexd/mnt/com.apple.MobileAsset.MetalToolchain-v27.2.47.0.Ex1CTN/Metal.xctoolchain --module-name KJamsClone --sdk-root /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk --xcode-version 27B52 --platform-family macOS --deployment-target 13.0 --bundle-identifier com.kjamsclone.app --output /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/Resources --target-triple arm64-apple-macos13.0 --target-triple x86_64-apple-macos13.0 --binary-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro --dependency-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro_dependency_info.dat --dependency-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro_dependency_info.dat --stringsdata-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/ExtractedAppShortcutsMetadata.stringsdata --stringsdata-file /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/ExtractedAppShortcutsMetadata.stringsdata --source-file-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro.SwiftFileList --source-file-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro.SwiftFileList --metadata-file-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro.DependencyMetadataFileList --static-metadata-file-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/KJMix\ Pro.DependencyStaticMetadataFileList --swift-const-vals-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/arm64/KJMix\ Pro.SwiftConstValuesFileList --swift-const-vals-list /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/Objects-normal/x86_64/KJMix\ Pro.SwiftConstValuesFileList --compile-time-extraction --deployment-aware-processing --no-app-shortcuts-localization +2026-11-03 12:51:08.484 appintentsmetadataprocessor[4635:83986] Starting App Intents metadata extraction +2026-11-03 12:51:08.486 appintentsmetadataprocessor[4635:83986] warning: Metadata extraction skipped, no AppIntents.framework dependency found + +ProcessInfoPlistFile /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/Info.plist /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/empty-KJMix\ Pro.plist (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-infoPlistUtility /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/empty-KJMix\ Pro.plist -producttype com.apple.product-type.application -genpkginfo /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/PkgInfo -expandbuildsettings -platform macosx -additionalcontentfile /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/assetcatalog_generated_info.plist -o /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/Info.plist + +CopySwiftLibs /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-swiftStdLibTool --copy --verbose --scan-executable /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/Frameworks --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/PlugIns --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/Library/SystemExtensions --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/Extensions --platform macosx --toolchain /var/run/com.apple.security.cryptexd/mnt/com.apple.MobileAsset.MetalToolchain-v27.2.47.0.Ex1CTN/Metal.xctoolchain --toolchain /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain --destination /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app/Contents/Frameworks --strip-bitcode --strip-bitcode-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/bitcode_strip --emit-dependency-info /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/KJMix\ Pro.build/Release/KJMix\ Pro.build/SwiftStdLibToolInputDependencies.dep --filter-for-swift-os --back-deploy-swift-span +Ignoring --strip-bitcode because --sign was not passed + +Validate /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-validationUtility /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app -no-validate-extension -infoplist-subpath Contents/Info.plist + +RegisterWithLaunchServices /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-lsregisterurl --record-path /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/XCBuildData/registered-launchservices.txt -- /System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister -f -R -trusted /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Products/Release/KJMix\ Pro.app + +PruneExplicitPrecompiledModules /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules + +PruneExplicitPrecompiledModules /Users/markgregory/Developer/Applications/KJamsClone/build-release/SDKExplicitPrecompiledModules + +PruneExplicitPrecompiledModules /Users/markgregory/Developer/Applications/KJamsClone/build-release/Build/Intermediates.noindex/ExplicitPrecompiledModules + +** BUILD SUCCEEDED ** + diff --git a/verification/xcode-test.log b/verification/xcode-test.log new file mode 100644 index 0000000..0c3344f --- /dev/null +++ b/verification/xcode-test.log @@ -0,0 +1,439 @@ +Command line invocation: + /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -project "KJMix Pro.xcodeproj" -scheme "KJMix Pro" -configuration Debug -destination platform=macOS -derivedDataPath /Users/markgregory/Developer/Applications/KJamsClone/build CODE_SIGNING_ALLOWED=NO test + +Build settings from command line: + CODE_SIGNING_ALLOWED = NO + +--- xcodebuild: WARNING: Using the first of multiple matching destinations: +{ platform:macOS, arch:arm64, id:00006030-000C01641102001C, name:My Mac } +{ platform:macOS, arch:x86_64, id:00006030-000C01641102001C, name:My Mac } +ComputePackagePrebuildTargetDependencyGraph + +Prepare packages + +CreateBuildRequest + +SendProjectDescription + +CreateBuildOperation + +ComputeTargetDependencyGraph +note: Building targets in dependency order +note: Target dependency graph (2 targets) + Target 'KJMixProTests' in project 'KJMix Pro' + ➜ Explicit dependency on target 'KJMix Pro' in project 'KJMix Pro' + Target 'KJMix Pro' in project 'KJMix Pro' (no dependencies) + +GatherProvisioningInputs + +CreateBuildDescription + +ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc --version + +ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -v -E -dM -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -x c -c /dev/null + +ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/usr/bin/actool --version --output-format xml1 + +ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ld -version_details + +Build description signature: 1db34c2a808128fe15dfcf68f2617d7e +Build description path: /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/XCBuildData/1db34c2a808128fe15dfcf68f2617d7e.xcbuilddata +ClangStatCache /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang-stat-cache /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache + cd /Users/markgregory/Developer/Applications/KJamsClone/KJMix\ Pro.xcodeproj + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang-stat-cache /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -o /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache + +SwiftDriver KJMixProTests normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-SwiftDriver -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJMixProTests -Onone @/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.SwiftFileList -plugin-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/plugins/testing -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Developer/Applications/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -Isystem /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -parse-as-library -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo + +SwiftDriver KJMixProTests normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-SwiftDriver -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJMixProTests -Onone @/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.SwiftFileList -plugin-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/plugins/testing -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Developer/Applications/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -Isystem /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -parse-as-library -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo + +ProcessInfoPlistFile /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/Info.plist /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/empty-KJMix\ Pro.plist (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-infoPlistUtility /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/empty-KJMix\ Pro.plist -producttype com.apple.product-type.application -genpkginfo /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PkgInfo -expandbuildsettings -platform macosx -additionalcontentfile /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/assetcatalog_generated_info.plist -o /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/Info.plist + +CopySwiftLibs /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-swiftStdLibTool --copy --verbose --scan-executable /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro.debug.dylib --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/Frameworks --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/Library/SystemExtensions --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/Extensions --platform macosx --toolchain /var/run/com.apple.security.cryptexd/mnt/com.apple.MobileAsset.MetalToolchain-v27.2.47.0.Ex1CTN/Metal.xctoolchain --toolchain /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain --destination /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/Frameworks --strip-bitcode --strip-bitcode-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/bitcode_strip --emit-dependency-info /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMix\ Pro.build/SwiftStdLibToolInputDependencies.dep --filter-for-swift-os --back-deploy-swift-span +Ignoring --strip-bitcode because --sign was not passed + +SwiftEmitModule normal arm64 Emitting\ module\ for\ KJMixProTests (in target 'KJMixProTests' from project 'KJMix Pro') + +SwiftCompile normal arm64 (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + + +SwiftEmitModule normal x86_64 Emitting\ module\ for\ KJMixProTests (in target 'KJMixProTests' from project 'KJMix Pro') + +SwiftCompile normal x86_64 (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + + +SwiftCompile normal x86_64 Compiling\ KJamsCloneTests.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift (in target 'KJMixProTests' from project 'KJMix Pro') + +SwiftCompile normal x86_64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-SwiftPerFileCompile KJamsCloneTests.swift + +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:756:73: warning: 'stringValue' was deprecated in macOS 13.0: Use load(.stringValue) instead [#DeprecatedDeclaration] + 754 | let files = try FileManager.default.contentsOfDirectory(at:backup,includingPropertiesForKeys:nil);XCTAssertEqual(files.count,1);XCTAssertEqual(try Data(contentsOf:files[0]),original) + 755 | let metadata = try await AVURLAsset(url:url).load(.commonMetadata) + 756 | XCTAssertEqual(metadata.first{$0.commonKey == .commonKeyTitle}?.stringValue,"Updated ✓") + | `- warning: 'stringValue' was deprecated in macOS 13.0: Use load(.stringValue) instead [#DeprecatedDeclaration] + 757 | var second = song;second.title = "Second";try await FileTagWriter.write(second,backupDirectory:backup) + 758 | XCTAssertEqual(try AudioWaveform.read(url:url,bins:64),before) + +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:824:73: warning: 'stringValue' was deprecated in macOS 13.0: Use load(.stringValue) instead [#DeprecatedDeclaration] + 822 | XCTAssertEqual(tracks.count,1) + 823 | let metadata = try await asset.load(.commonMetadata) + 824 | XCTAssertEqual(metadata.first{$0.commonKey == .commonKeyTitle}?.stringValue,"Tagged Video") + | `- warning: 'stringValue' was deprecated in macOS 13.0: Use load(.stringValue) instead [#DeprecatedDeclaration] + 825 | } + 826 | + +[#DeprecatedDeclaration]: +SwiftCompile normal arm64 Compiling\ KJamsCloneTests.swift /Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift (in target 'KJMixProTests' from project 'KJMix Pro') +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:756:73: warning: 'stringValue' was deprecated in macOS 13.0: Use load(.stringValue) instead [#DeprecatedDeclaration] + 754 | let files = try FileManager.default.contentsOfDirectory(at:backup,includingPropertiesForKeys:nil);XCTAssertEqual(files.count,1);XCTAssertEqual(try Data(contentsOf:files[0]),original) + 755 | let metadata = try await AVURLAsset(url:url).load(.commonMetadata) + 756 | XCTAssertEqual(metadata.first{$0.commonKey == .commonKeyTitle}?.stringValue,"Updated ✓") + | `- warning: 'stringValue' was deprecated in macOS 13.0: Use load(.stringValue) instead [#DeprecatedDeclaration] + 757 | var second = song;second.title = "Second";try await FileTagWriter.write(second,backupDirectory:backup) + 758 | XCTAssertEqual(try AudioWaveform.read(url:url,bins:64),before) + +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:824:73: warning: 'stringValue' was deprecated in macOS 13.0: Use load(.stringValue) instead [#DeprecatedDeclaration] + 822 | XCTAssertEqual(tracks.count,1) + 823 | let metadata = try await asset.load(.commonMetadata) + 824 | XCTAssertEqual(metadata.first{$0.commonKey == .commonKeyTitle}?.stringValue,"Tagged Video") + | `- warning: 'stringValue' was deprecated in macOS 13.0: Use load(.stringValue) instead [#DeprecatedDeclaration] + 825 | } + 826 | + +[#DeprecatedDeclaration]: + +SwiftCompile normal arm64 /Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-SwiftPerFileCompile KJamsCloneTests.swift + +SwiftDriverJobDiscovery normal arm64 Emitting module for KJMixProTests (in target 'KJMixProTests' from project 'KJMix Pro') + +SwiftDriver\ Compilation\ Requirements KJMixProTests normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-Swift-Compilation-Requirements -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJMixProTests -Onone @/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.SwiftFileList -plugin-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/plugins/testing -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Developer/Applications/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -Isystem /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -parse-as-library -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo + +Copy /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMixProTests.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.swiftsourceinfo (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.swiftsourceinfo /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMixProTests.swiftmodule/Project/arm64-apple-macos.swiftsourceinfo + +SwiftDriverJobDiscovery normal x86_64 Emitting module for KJMixProTests (in target 'KJMixProTests' from project 'KJMix Pro') + +SwiftDriver\ Compilation\ Requirements KJMixProTests normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-Swift-Compilation-Requirements -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJMixProTests -Onone @/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.SwiftFileList -plugin-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/plugins/testing -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Developer/Applications/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -Isystem /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -parse-as-library -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo + +Copy /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMixProTests.swiftmodule/arm64-apple-macos.swiftmodule /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.swiftmodule (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.swiftmodule /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMixProTests.swiftmodule/arm64-apple-macos.swiftmodule + +Copy /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMixProTests.swiftmodule/arm64-apple-macos.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.abi.json (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMixProTests.swiftmodule/arm64-apple-macos.abi.json + +Copy /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMixProTests.swiftmodule/x86_64-apple-macos.swiftmodule /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.swiftmodule (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.swiftmodule /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMixProTests.swiftmodule/x86_64-apple-macos.swiftmodule + +Copy /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMixProTests.swiftmodule/Project/x86_64-apple-macos.swiftsourceinfo /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.swiftsourceinfo (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.swiftsourceinfo /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMixProTests.swiftmodule/Project/x86_64-apple-macos.swiftsourceinfo + +Copy /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMixProTests.swiftmodule/x86_64-apple-macos.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.abi.json (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-copy -exclude .DS_Store -exclude CVS -exclude .svn -exclude .git -exclude .hg -strip-unsigned-binaries -strip-deterministic -strip-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip -resolve-src-symlinks -rename /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.abi.json /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMixProTests.swiftmodule/x86_64-apple-macos.abi.json + +SwiftDriverJobDiscovery normal arm64 Compiling KJamsCloneTests.swift (in target 'KJMixProTests' from project 'KJMix Pro') + +SwiftDriver\ Compilation KJMixProTests normal arm64 com.apple.xcode.tools.swift.compiler (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-Swift-Compilation -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJMixProTests -Onone @/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.SwiftFileList -plugin-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/plugins/testing -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target arm64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Developer/Applications/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -Isystem /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -parse-as-library -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources-normal/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources/arm64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo + +Ld /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/Binary/KJMixProTests normal arm64 (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target arm64-apple-macos13.0 -bundle -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -Os -Xlinker -no_warn_duplicate_libraries -L/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -L/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -L/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -F/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -iframework /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -filelist /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.LinkFileList -Xlinker -rpath -Xlinker /usr/lib/swift -Xlinker -rpath -Xlinker @loader_path/../Frameworks -Xlinker -rpath -Xlinker @executable_path/../Frameworks -bundle_loader /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro -Xlinker -object_path_lto -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests_lto.o -rdynamic -Xlinker -dependency_info -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests_dependency_info.dat -fobjc-link-runtime -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx -L/usr/lib/swift -Xlinker -add_ast_path -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.swiftmodule -lsqlite3 -Xlinker -needed_framework -Xlinker XCTest -framework XCTest -Xlinker -needed-lXCTestSwiftSupport -lXCTestSwiftSupport /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro.debug.dylib -o /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/Binary/KJMixProTests +/Users/markgregory/Developer/Applications/KJamsClone/KJMix Pro.xcodeproj: KJMixProTests: ld: warning: building for macOS-13.0, but linking with dylib '@rpath/XCTest.framework/Versions/A/XCTest' which was built for newer version 14.0 +/Users/markgregory/Developer/Applications/KJamsClone/KJMix Pro.xcodeproj: KJMixProTests: ld: warning: building for macOS-13.0, but linking with dylib '@rpath/libXCTestSwiftSupport.dylib' which was built for newer version 14.0 + +SwiftDriverJobDiscovery normal x86_64 Compiling KJamsCloneTests.swift (in target 'KJMixProTests' from project 'KJMix Pro') + +SwiftDriver\ Compilation KJMixProTests normal x86_64 com.apple.xcode.tools.swift.compiler (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-Swift-Compilation -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name KJMixProTests -Onone @/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.SwiftFileList -plugin-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/plugins/testing -enable-bare-slash-regex -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -target x86_64-apple-macos13.0 -g -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/markgregory/Developer/Applications/KJamsClone/build/Index.noindex/DataStore -swift-version 5 -I /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -Isystem /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -F /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -parse-as-library -c -j11 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/SDKStatCaches.noindex/macosx27.2-26B118-9aa026f88bf9ed31cff8761912ac5bd1a7ff82d74745d5ef33555b35c4238456.sdkstatcache -output-file-map /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests-OutputFileMap.json -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex -sdk-module-cache-path /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules -emit-dependencies -emit-module -emit-module-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.swiftmodule -serialize-diagnostics -dependency-scan-serialize-diagnostics-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests-dependency-scan.dia -validate-clang-modules-once -clang-build-session-file /Users/markgregory/Developer/Applications/KJamsClone/build/ModuleCache.noindex/Session.modulevalidation -Xcc -fmodules-prune-interval\=86400 -Xcc -fmodules-prune-after\=345600 -emit-const-values -const-gather-protocols-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests_const_extract_protocols.json -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-generated-files.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-own-target-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-all-target-headers.hmap -Xcc -iquote -Xcc /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests-project-headers.hmap -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/include -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources-normal/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources/x86_64 -Xcc -I/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/DerivedSources -emit-objc-header -emit-objc-header-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests-Swift.h -working-directory /Users/markgregory/Developer/Applications/KJamsClone -experimental-emit-module-separately -disable-cmo + +Ld /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/Binary/KJMixProTests normal x86_64 (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -Xlinker -reproducible -target x86_64-apple-macos13.0 -bundle -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk -Os -Xlinker -no_warn_duplicate_libraries -L/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -L/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -L/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib -F/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/EagerLinkingTBDs/Debug -F/Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug -iframework /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks -filelist /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.LinkFileList -Xlinker -rpath -Xlinker /usr/lib/swift -Xlinker -rpath -Xlinker @loader_path/../Frameworks -Xlinker -rpath -Xlinker @executable_path/../Frameworks -bundle_loader /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro -Xlinker -object_path_lto -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests_lto.o -rdynamic -Xlinker -dependency_info -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests_dependency_info.dat -fobjc-link-runtime -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx -L/usr/lib/swift -Xlinker -add_ast_path -Xlinker /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.swiftmodule -lsqlite3 -Xlinker -needed_framework -Xlinker XCTest -framework XCTest -Xlinker -needed-lXCTestSwiftSupport -lXCTestSwiftSupport /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/MacOS/KJMix\ Pro.debug.dylib -o /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/Binary/KJMixProTests +/Users/markgregory/Developer/Applications/KJamsClone/KJMix Pro.xcodeproj: KJMixProTests: ld: warning: building for macOS-13.0, but linking with dylib '@rpath/XCTest.framework/Versions/A/XCTest' which was built for newer version 14.0 +/Users/markgregory/Developer/Applications/KJamsClone/KJMix Pro.xcodeproj: KJMixProTests: ld: warning: building for macOS-13.0, but linking with dylib '@rpath/libXCTestSwiftSupport.dylib' which was built for newer version 14.0 + +CreateUniversalBinary /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/MacOS/KJMixProTests normal arm64\ x86_64 (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/lipo -create /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/Binary/KJMixProTests /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/Binary/KJMixProTests -output /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/MacOS/KJMixProTests + +CopySwiftLibs /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-swiftStdLibTool --copy --verbose --scan-executable /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/MacOS/KJMixProTests --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/Frameworks --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/PlugIns --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/Library/SystemExtensions --scan-folder /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/Extensions --platform macosx --toolchain /var/run/com.apple.security.cryptexd/mnt/com.apple.MobileAsset.MetalToolchain-v27.2.47.0.Ex1CTN/Metal.xctoolchain --toolchain /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain --destination /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/Frameworks --strip-bitcode --scan-executable /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib/libXCTestSwiftSupport.dylib --strip-bitcode-tool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/bitcode_strip --emit-dependency-info /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/SwiftStdLibToolInputDependencies.dep --filter-for-swift-os --back-deploy-swift-span +Ignoring --strip-bitcode because --sign was not passed + +ExtractAppIntentsMetadata (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/appintentsmetadataprocessor --toolchain-dir /var/run/com.apple.security.cryptexd/mnt/com.apple.MobileAsset.MetalToolchain-v27.2.47.0.Ex1CTN/Metal.xctoolchain --module-name KJMixProTests --sdk-root /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.2.sdk --xcode-version 27B52 --platform-family macOS --deployment-target 13.0 --bundle-identifier com.kjamsclone.tests --output /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/Resources --target-triple arm64-apple-macos13.0 --target-triple x86_64-apple-macos13.0 --binary-file /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/MacOS/KJMixProTests --dependency-file /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests_dependency_info.dat --dependency-file /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests_dependency_info.dat --stringsdata-file /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/ExtractedAppShortcutsMetadata.stringsdata --stringsdata-file /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/ExtractedAppShortcutsMetadata.stringsdata --source-file-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.SwiftFileList --source-file-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.SwiftFileList --metadata-file-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests.DependencyMetadataFileList --static-metadata-file-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/KJMixProTests.DependencyStaticMetadataFileList --swift-const-vals-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/arm64/KJMixProTests.SwiftConstValuesFileList --swift-const-vals-list /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/Objects-normal/x86_64/KJMixProTests.SwiftConstValuesFileList --compile-time-extraction --deployment-aware-processing --no-app-shortcuts-localization +2026-11-03 12:50:36.525 appintentsmetadataprocessor[4554:82704] Starting App Intents metadata extraction +2026-11-03 12:50:36.527 appintentsmetadataprocessor[4554:82704] warning: Metadata extraction skipped, no AppIntents.framework dependency found + +ProcessInfoPlistFile /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/Info.plist /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/empty-KJMixProTests.plist (in target 'KJMixProTests' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-infoPlistUtility /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/KJMix\ Pro.build/Debug/KJMixProTests.build/empty-KJMixProTests.plist -producttype com.apple.product-type.bundle.unit-test -expandbuildsettings -platform macosx -o /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app/Contents/PlugIns/KJMixProTests.xctest/Contents/Info.plist + +Validate /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-validationUtility /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app -no-validate-extension -infoplist-subpath Contents/Info.plist + +RegisterWithLaunchServices /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app (in target 'KJMix Pro' from project 'KJMix Pro') + cd /Users/markgregory/Developer/Applications/KJamsClone + builtin-lsregisterurl --record-path /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/XCBuildData/registered-launchservices.txt -- /System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister -f -R -trusted /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Products/Debug/KJMix\ Pro.app + +PruneExplicitPrecompiledModules /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/ExplicitPrecompiledModules + +PruneExplicitPrecompiledModules /Users/markgregory/Developer/Applications/KJamsClone/build/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules + +PruneExplicitPrecompiledModules /Users/markgregory/Developer/Applications/KJamsClone/build/SDKExplicitPrecompiledModules + +2026-11-03 12:50:36.866291+0000 KJMix Pro[4556:82754] [Connection] Unable to get remoteObjectProxy, error: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.linkd.autoShortcut" UserInfo={NSDebugDescription=connection to service named com.apple.linkd.autoShortcut} +2026-11-03 12:50:36.866459+0000 KJMix Pro[4556:82754] [Application] Error registering app with AppIntents framework: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.linkd.autoShortcut" UserInfo={NSDebugDescription=connection to service named com.apple.linkd.autoShortcut} +2026-11-03 12:50:36.913426+0000 KJMix Pro[4556:82732] [plugin] AddInstanceForFactory: No factory registered for id F8BB1C28-BAE8-11D6-9C31-00039315CD46 +2026-11-03 12:50:36.941831+0000 KJMix Pro[4556:82732] [logging-persist] cannot open file at line 53094 of [8fa8248e30] +2026-11-03 12:50:36.941850+0000 KJMix Pro[4556:82732] [logging-persist] os_unix.c:53094: (2) open(/private/var/db/DetachedSignatures) - No such file or directory +2026-11-03 12:50:37.080460+0000 KJMix Pro[4556:82732] ApplePersistenceIgnoreState: Existing state will not be touched. New state will be written to /var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/com.kjamsclone.app.savedState +Test Suite 'All tests' started at 2026-11-03 12:50:37.639. +Test Suite 'KJMixProTests.xctest' started at 2026-11-03 12:50:37.640. +Test Suite 'KJamsCloneTests' started at 2026-11-03 12:50:37.640. +Test Case '-[KJMixProTests.KJamsCloneTests testActualRotationButtonsAddSongReorderAndStart]' started. +2026-11-03 12:50:37.744531+0000 KJMix Pro[4556:82732] [Window] Warning: -[NSWindow makeKeyWindow] called on windowNumber=225 which returned NO from -[NSWindow canBecomeKeyWindow]. +2026-11-03 12:50:37.899096+0000 KJMix Pro[4556:82825] [Connection] Unable to get remoteObjectProxy, error: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.linkd.autoShortcut" UserInfo={NSDebugDescription=connection to service named com.apple.linkd.autoShortcut} +2026-11-03 12:50:37.899130+0000 KJMix Pro[4556:82825] [Connection] Unable to re-register with Process Instance Registry, error: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.linkd.autoShortcut" UserInfo={NSDebugDescription=connection to service named com.apple.linkd.autoShortcut} +2026-11-03 12:50:39.518489+0000 KJMix Pro[4556:82842] [logging] BUG IN CLIENT OF libsqlite3.dylib: database integrity compromised by API violation: vnode unlinked while in use: /private/var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/9AD2D63C-BF16-460B-BF0C-109CEDA59CCF/Library.sqlite-wal +2026-11-03 12:50:39.518512+0000 KJMix Pro[4556:82842] [logging] invalidated open fd: 25 (0x11) +2026-11-03 12:50:39.518516+0000 KJMix Pro[4556:82754] [logging] BUG IN CLIENT OF libsqlite3.dylib: database integrity compromised by API violation: vnode unlinked while in use: /private/var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/9AD2D63C-BF16-460B-BF0C-109CEDA59CCF/Library.sqlite-shm +2026-11-03 12:50:39.518527+0000 KJMix Pro[4556:82754] [logging] invalidated open fd: 26 (0x11) +2026-11-03 12:50:39.518743+0000 KJMix Pro[4556:82754] [logging] BUG IN CLIENT OF libsqlite3.dylib: database integrity compromised by API violation: vnode unlinked while in use: /private/var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/9AD2D63C-BF16-460B-BF0C-109CEDA59CCF/Library.sqlite +2026-11-03 12:50:39.518751+0000 KJMix Pro[4556:82754] [logging] invalidated open fd: 24 (0x11) +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:0: warning: -[KJMixProTests.KJamsCloneTests testActualRotationButtonsAddSongReorderAndStart] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testActualRotationButtonsAddSongReorderAndStart]' passed (1.917 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testActualSingerDisclosurePressAndNavigationRetainExpansion]' started. +2026-11-03 12:50:39.624346+0000 KJMix Pro[4556:82732] [Window] Warning: -[NSWindow makeKeyWindow] called on windowNumber=227 which returned NO from -[NSWindow canBecomeKeyWindow]. +2026-11-03 12:50:39.936416+0000 KJMix Pro[4556:82842] [Connection] Unable to get remoteObjectProxy, error: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.linkd.autoShortcut" UserInfo={NSDebugDescription=connection to service named com.apple.linkd.autoShortcut} +2026-11-03 12:50:39.936451+0000 KJMix Pro[4556:82842] [Connection] Unable to re-register with Process Instance Registry, error: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.linkd.autoShortcut" UserInfo={NSDebugDescription=connection to service named com.apple.linkd.autoShortcut} +2026-11-03 12:50:41.115238+0000 KJMix Pro[4556:82836] [logging] BUG IN CLIENT OF libsqlite3.dylib: database integrity compromised by API violation: vnode unlinked while in use: /private/var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/98C54FE0-2DE7-4671-AD5F-357107F934C6/Library.sqlite-wal +2026-11-03 12:50:41.115267+0000 KJMix Pro[4556:82836] [logging] invalidated open fd: 25 (0x11) +2026-11-03 12:50:41.115270+0000 KJMix Pro[4556:82757] [logging] BUG IN CLIENT OF libsqlite3.dylib: database integrity compromised by API violation: vnode unlinked while in use: /private/var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/98C54FE0-2DE7-4671-AD5F-357107F934C6/Library.sqlite-shm +2026-11-03 12:50:41.115285+0000 KJMix Pro[4556:82757] [logging] invalidated open fd: 26 (0x11) +2026-11-03 12:50:41.115361+0000 KJMix Pro[4556:82757] [logging] BUG IN CLIENT OF libsqlite3.dylib: database integrity compromised by API violation: vnode unlinked while in use: /private/var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/98C54FE0-2DE7-4671-AD5F-357107F934C6/Library.sqlite +2026-11-03 12:50:41.115368+0000 KJMix Pro[4556:82757] [logging] invalidated open fd: 24 (0x11) +Test Case '-[KJMixProTests.KJamsCloneTests testActualSingerDisclosurePressAndNavigationRetainExpansion]' passed (1.572 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testAllStoreProfilesAndCatalogueValidation]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testAllStoreProfilesAndCatalogueValidation]' passed (0.025 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testAutomaticBackgroundHandoffAndManualOverride]' started. +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:947: warning: -[KJMixProTests.KJamsCloneTests testAutomaticBackgroundHandoffAndManualOverride] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testAutomaticBackgroundHandoffAndManualOverride]' passed (1.829 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testAutomaticVenueQueueAdvancesAndRecordsSinger]' started. +2026-11-03 12:50:43.235004+0000 KJMix Pro[4556:82732] [ClientDonation] (+[PPSClientDonation isRegisteredSubsystem:category:]) Permission denied: CoreMedia / CM-ASSETCREATION +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:225: warning: -[KJMixProTests.KJamsCloneTests testAutomaticVenueQueueAdvancesAndRecordsSinger] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testAutomaticVenueQueueAdvancesAndRecordsSinger]' passed (0.745 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testBackgroundFadesSaveSettingsAndCancelOnSeekOrSinger]' started. +2026-11-03 12:50:43.946975+0000 KJMix Pro[4556:82837] [Connection] Will NOT re-try to establish the connection +2026-11-03 12:50:43.947101+0000 KJMix Pro[4556:82837] [Connection] Unable to get remoteObjectProxy, error: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.linkd.autoShortcut" UserInfo={NSDebugDescription=connection to service named com.apple.linkd.autoShortcut} +2026-11-03 12:50:43.947173+0000 KJMix Pro[4556:82837] [Connection] Unable to re-register with Process Instance Registry, error: Error Domain=NSCocoaErrorDomain Code=4097 "connection to service named com.apple.linkd.autoShortcut" UserInfo={NSDebugDescription=connection to service named com.apple.linkd.autoShortcut} +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:1085: warning: -[KJMixProTests.KJamsCloneTests testBackgroundFadesSaveSettingsAndCancelOnSeekOrSinger] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testBackgroundFadesSaveSettingsAndCancelOnSeekOrSinger]' passed (1.692 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testBackgroundSeekRetainsPlaybackStateAndSingerInterlock]' started. +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:1065: warning: -[KJMixProTests.KJamsCloneTests testBackgroundSeekRetainsPlaybackStateAndSingerInterlock] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testBackgroundSeekRetainsPlaybackStateAndSingerInterlock]' passed (0.335 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testBatchMetadataIsAtomicAndPlaylistOrderPersists]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testBatchMetadataIsAtomicAndPlaylistOrderPersists]' passed (0.015 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testBulkTuningCacheSwitchesSingersAndUpdatesWithoutLibraryReload]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testBulkTuningCacheSwitchesSingersAndUpdatesWithoutLibraryReload]' passed (0.045 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testConnectedSingerCreationAndSharedRepertoire]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testConnectedSingerCreationAndSharedRepertoire]' passed (0.105 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testCreateSingerAndVenueThroughAppModel]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testCreateSingerAndVenueThroughAppModel]' passed (0.061 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testDJStopCueHotCuesJogAndLoopKeepTrackLoaded]' started. +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:831: warning: -[KJMixProTests.KJamsCloneTests testDJStopCueHotCuesJogAndLoopKeepTrackLoaded] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testDJStopCueHotCuesJogAndLoopKeepTrackLoaded]' passed (0.572 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testEditableSongDefaultsAndSingerOverridesAreIndependent]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testEditableSongDefaultsAndSingerOverridesAreIndependent]' passed (0.077 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testFadePreservesGlobalMasterAndCancelsForNextSong]' started. +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:739: warning: -[KJMixProTests.KJamsCloneTests testFadePreservesGlobalMasterAndCancelsForNextSong] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testFadePreservesGlobalMasterAndCancelsForNextSong]' passed (0.739 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testFolderScanReferencesAudioAndPreparesReusableWaveform]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testFolderScanReferencesAudioAndPreparesReusableWaveform]' passed (0.196 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testFrequencyBandsDistinguishBassAndTreble]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testFrequencyBandsDistinguishBassAndTreble]' passed (0.004 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testHTTPParserRejectsSmugglingOversizeAndWaitsForBody]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testHTTPParserRejectsSmugglingOversizeAndWaitsForBody]' passed (0.001 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testLowPassAndHighPassFilterActualAudio]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testLowPassAndHighPassFilterActualAudio]' passed (0.038 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testManualBackgroundStopsBeforeSingerAndNeverAutoResumes]' started. +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:854: warning: -[KJMixProTests.KJamsCloneTests testManualBackgroundStopsBeforeSingerAndNeverAutoResumes] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testManualBackgroundStopsBeforeSingerAndNeverAutoResumes]' passed (0.273 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testMetadataEditsPersistAndSurviveReimport]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testMetadataEditsPersistAndSurviveReimport]' passed (0.008 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testMigrationAndMembershipRemovalDoesNotReappear]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testMigrationAndMembershipRemovalDoesNotReappear]' passed (0.013 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testMixerIsGlobalAndPersistsWithoutChangingTrackPreferences]' started. +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:646: warning: -[KJMixProTests.KJamsCloneTests testMixerIsGlobalAndPersistsWithoutChangingTrackPreferences] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testMixerIsGlobalAndPersistsWithoutChangingTrackPreferences]' passed (0.185 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testMP3FileTagWritingBacksUpPreservesAudioAndRejectsBadTags]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testMP3FileTagWritingBacksUpPreservesAudioAndRejectsBadTags]' passed (0.165 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testMP3PlaybackPauseAndSeek]' started. +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:85: warning: -[KJMixProTests.KJamsCloneTests testMP3PlaybackPauseAndSeek] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testMP3PlaybackPauseAndSeek]' passed (0.136 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testMP4ImportWaveformAndPlaybackThroughGlobalMixer]' started. +2026-11-03 12:50:48.487177+0000 KJMix Pro[4556:82836] [player] <<<< FigFilePlayer >>>> signalled err=-12864 at <>:10696 +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:673: warning: -[KJMixProTests.KJamsCloneTests testMP4ImportWaveformAndPlaybackThroughGlobalMixer] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testMP4ImportWaveformAndPlaybackThroughGlobalMixer]' passed (0.295 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testMP4TagExportPreservesVideoAndAudio]' started. +2026-11-03 12:50:48.716632+0000 KJMix Pro[4556:83255] [] <<< FigReportingAgentRef >>> signalled err=-12005 at <>:1605 +2026-11-03 12:50:48.716654+0000 KJMix Pro[4556:83255] [] <<< FigReportingAgentRef >>> signalled err=-12005 at <>:1605 +2026-11-03 12:50:48.716665+0000 KJMix Pro[4556:83255] [] <<< FigPlayerModerator >>> signalled err=-17511 at <>:2110 +2026-11-03 12:50:48.716860+0000 KJMix Pro[4556:83255] [player] <<<< FigAirPlay_Route >>>> signalled err=-12860 at <>:696 +2026-11-03 12:50:48.748849+0000 KJMix Pro[4556:83259] [] <<<< FMDW_UserData >>>> signalled err=-12583 at <>:1072 +2026-11-03 12:50:48.748867+0000 KJMix Pro[4556:83259] [] <<<< FMDW_UserData >>>> signalled err=-12583 at <>:1072 +2026-11-03 12:50:48.748877+0000 KJMix Pro[4556:83259] [] <<<< FMDW_UserData >>>> signalled err=-12583 at <>:1072 +2026-11-03 12:50:48.749316+0000 KJMix Pro[4556:82754] [ClientDonation] (+[PPSClientDonation isRegisteredSubsystem:category:]) Permission denied: CoreMedia / CM-EXPORT +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:819: warning: -[KJMixProTests.KJamsCloneTests testMP4TagExportPreservesVideoAndAudio] : [Internal] Thread running at User-initiated quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testMP4TagExportPreservesVideoAndAudio]' passed (0.112 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testMusicStoreDefinitionsValidateSearchAndPersist]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testMusicStoreDefinitionsValidateSearchAndPersist]' passed (0.006 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testNativeSongDragProviderRoundTrip]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testNativeSongDragProviderRoundTrip]' passed (0.001 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testNaturalFinishAdvancesSingerBeforeBackgroundThenResumesWhenEmpty]' started. +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:978: warning: -[KJMixProTests.KJamsCloneTests testNaturalFinishAdvancesSingerBeforeBackgroundThenResumesWhenEmpty] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testNaturalFinishAdvancesSingerBeforeBackgroundThenResumesWhenEmpty]' passed (1.567 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testNewSingerSongsImmediatelyJoinSmartRotation]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testNewSingerSongsImmediatelyJoinSmartRotation]' passed (0.032 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testNewTestMediaMetadataAndCDGPairImport]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testNewTestMediaMetadataAndCDGPairImport]' passed (0.017 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testOfflineLocationAndDuplicateSourcePreserveLibrary]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testOfflineLocationAndDuplicateSourcePreserveLibrary]' passed (0.070 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testPairedImportAndContentDedup]' started. +2026-11-03 12:50:50.501173+0000 KJMix Pro[4556:83232] [] <<< FFR_Common >>> signalled err=-12873 at <>:4132 +2026-11-03 12:50:50.501312+0000 KJMix Pro[4556:82732] MP3AudioFile.cpp:1565 Problem scanning for packets +2026-11-03 12:50:50.501322+0000 KJMix Pro[4556:82732] MP3AudioFile.cpp:1067 MPEGAudioFile::OpenFromDataSource failed +2026-11-03 12:50:50.501333+0000 KJMix Pro[4556:82732] AudioFileObject.cpp:106 OpenFromDataSource failed +2026-11-03 12:50:50.501336+0000 KJMix Pro[4556:82732] AudioFileObject.cpp:81 Open failed +2026-11-03 12:50:50.501364+0000 KJMix Pro[4556:82732] [avae] AVAEInternal.h:109 [AVAudioFile.mm:305:AVAudioFileImpl: (ExtAudioFileOpenURL((__bridge CFURLRef)fileURL, &_extAudioFile)): error 1685348671 +2026-11-03 12:50:50.501917+0000 KJMix Pro[4556:83232] [] <<< FFR_Common >>> signalled err=-12873 at <>:4132 +2026-11-03 12:50:50.502038+0000 KJMix Pro[4556:82732] MP3AudioFile.cpp:1565 Problem scanning for packets +2026-11-03 12:50:50.502044+0000 KJMix Pro[4556:82732] MP3AudioFile.cpp:1067 MPEGAudioFile::OpenFromDataSource failed +2026-11-03 12:50:50.502047+0000 KJMix Pro[4556:82732] AudioFileObject.cpp:106 OpenFromDataSource failed +2026-11-03 12:50:50.502050+0000 KJMix Pro[4556:82732] AudioFileObject.cpp:81 Open failed +2026-11-03 12:50:50.502073+0000 KJMix Pro[4556:82732] [avae] AVAEInternal.h:109 [AVAudioFile.mm:305:AVAudioFileImpl: (ExtAudioFileOpenURL((__bridge CFURLRef)fileURL, &_extAudioFile)): error 1685348671 +Test Case '-[KJMixProTests.KJamsCloneTests testPairedImportAndContentDedup]' passed (0.009 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testPaletteBorderScrollAndImage]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testPaletteBorderScrollAndImage]' passed (0.036 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testPauseAndUnloadCancelSingerWaitingForBackgroundFade]' started. +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:1125: warning: -[KJMixProTests.KJamsCloneTests testPauseAndUnloadCancelSingerWaitingForBackgroundFade] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testPauseAndUnloadCancelSingerWaitingForBackgroundFade]' passed (1.013 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testPlayerReusesSavedWaveformAndCanForceReanalysis]' started. +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:451: warning: -[KJMixProTests.KJamsCloneTests testPlayerReusesSavedWaveformAndCanForceReanalysis] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testPlayerReusesSavedWaveformAndCanForceReanalysis]' passed (0.450 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testPresetTileXORAndInvalidCoordinates]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testPresetTileXORAndInvalidCoordinates]' passed (0.001 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testQueuePlaylistsPreferencesAndDeletion]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testQueuePlaylistsPreferencesAndDeletion]' passed (0.008 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testQueueRemembersSingerSongWithoutDuplicatingAssignment]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testQueueRemembersSingerSongWithoutDuplicatingAssignment]' passed (0.005 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testRapidCDGJogCannotPublishAnOldSeekFrame]' started. +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:926: warning: -[KJMixProTests.KJamsCloneTests testRapidCDGJogCannotPublishAnOldSeekFrame] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testRapidCDGJogCannotPublishAnOldSeekFrame]' passed (0.494 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testReorderingPlayedTonightDoesNotQueueItAgain]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testReorderingPlayedTonightDoesNotQueueItAgain]' passed (0.042 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testRotationDragTargetsExplicitVenueAndReordersRoster]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testRotationDragTargetsExplicitVenueAndReordersRoster]' passed (0.034 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testRotationSettingsRosterAndPersonalRatingsPersistPerVenue]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testRotationSettingsRosterAndPersonalRatingsPersistPerVenue]' passed (0.054 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testRotationStartPauseResumeStopAndEmptyExplanation]' started. +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:999: warning: -[KJMixProTests.KJamsCloneTests testRotationStartPauseResumeStopAndEmptyExplanation] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testRotationStartPauseResumeStopAndEmptyExplanation]' passed (0.123 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testSeekAndTruncatedPacket]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testSeekAndTruncatedPacket]' passed (0.001 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testSettingsPersistWithoutAutoplayAndPreserveMaster]' started. +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:866: warning: -[KJMixProTests.KJamsCloneTests testSettingsPersistWithoutAutoplayAndPreserveMaster] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testSettingsPersistWithoutAutoplayAndPreserveMaster]' passed (0.532 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testShowScreensPersistRenderPauseSeekAndFinish]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testShowScreensPersistRenderPauseSeekAndFinish]' passed (0.225 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testSingerAPIIsolationRevocationAndPersonalSettings]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testSingerAPIIsolationRevocationAndPersonalSettings]' passed (0.051 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testSingerWaitsForAudibleBackgroundFadeOut]' started. +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:1108: warning: -[KJMixProTests.KJamsCloneTests testSingerWaitsForAudibleBackgroundFadeOut] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testSingerWaitsForAudibleBackgroundFadeOut]' passed (1.252 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testSingerWebServerRealLoopbackHTTP]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testSingerWebServerRealLoopbackHTTP]' passed (0.097 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testSmartRotationInterleavesPreservesEmptyAndDepartedCursor]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testSmartRotationInterleavesPreservesEmptyAndDepartedCursor]' passed (0.001 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testSongDropCopiesToTargetSingerAndRejectsWrongContext]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testSongDropCopiesToTargetSingerAndRejectsWrongContext]' passed (0.030 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testSourceZipUsesManagedMediaAndCancellationKeepsCompletedIndex]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testSourceZipUsesManagedMediaAndCancellationKeepsCompletedIndex]' passed (0.233 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testSQLitePersistenceDedupRatingsAndHistory]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testSQLitePersistenceDedupRatingsAndHistory]' passed (0.009 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testStandardMacPasteboardCarriesKJMixSongAndRejectsOtherText]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testStandardMacPasteboardCarriesKJMixSongAndRejectsOtherText]' passed (0.003 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testTargetedMultiTermQuotedExactAndNumericSearch]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testTargetedMultiTermQuotedExactAndNumericSearch]' passed (0.001 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testTempoSourceClockPauseAndResume]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testTempoSourceClockPauseAndResume]' passed (0.991 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testToneEQBoostAndBypassProcessActualAudio]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testToneEQBoostAndBypassProcessActualAudio]' passed (0.028 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testTonightDragReorderPersistsAndKeepsOtherSingerSlots]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testTonightDragReorderPersistsAndKeepsOtherSingerSlots]' passed (0.053 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testTonightMigrationAndVenueIsolation]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testTonightMigrationAndVenueIsolation]' passed (0.009 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testTonightPersistsForEachSingerAcrossSwitchPlaybackAndRestart]' started. +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:474: warning: -[KJMixProTests.KJamsCloneTests testTonightPersistsForEachSingerAcrossSwitchPlaybackAndRestart] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testTonightPersistsForEachSingerAcrossSwitchPlaybackAndRestart]' passed (0.133 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testUnifiedRosterDropRoutesSingerAndSongProviders]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testUnifiedRosterDropRoutesSingerAndSongProviders]' passed (0.352 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testVenuesShareSingersAndRememberSelection]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testVenuesShareSingersAndRememberSelection]' passed (0.074 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testVersionThreeMigrationBuildsSingerSongListsFromHistory]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testVersionThreeMigrationBuildsSingerSongListsFromHistory]' passed (0.008 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testVisibleSingerReorderUpdatesManualAndSmartPlansAndPersists]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testVisibleSingerReorderUpdatesManualAndSmartPlansAndPersists]' passed (0.052 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testWaveformAnalysisAndStopClearsTrackData]' started. +/Users/markgregory/Developer/Applications/KJamsClone/KJamsCloneTests/KJamsCloneTests.swift:263: warning: -[KJMixProTests.KJamsCloneTests testWaveformAnalysisAndStopClearsTrackData] : [Internal] Thread running at User-interactive quality-of-service class waiting on a lower QoS thread running at Default quality-of-service class. Investigate ways to avoid priority inversions +Test Case '-[KJMixProTests.KJamsCloneTests testWaveformAnalysisAndStopClearsTrackData]' passed (0.316 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testWaveformCacheRejectsChangedFilesAndCorruption]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testWaveformCacheRejectsChangedFilesAndCorruption]' passed (0.098 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testWaveformRepresentsSilenceAndSignalInTimeOrder]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testWaveformRepresentsSilenceAndSignalInTimeOrder]' passed (0.003 seconds). +Test Case '-[KJMixProTests.KJamsCloneTests testZippedMP3GImportAndUnsafeArchiveRejection]' started. +Test Case '-[KJMixProTests.KJamsCloneTests testZippedMP3GImportAndUnsafeArchiveRejection]' passed (0.361 seconds). +Test Suite 'KJamsCloneTests' passed at 2026-11-03 12:50:57.685. + Executed 69 tests, with 0 failures (0 unexpected) in 20.026 (20.045) seconds +Test Suite 'KJMixProTests.xctest' passed at 2026-11-03 12:50:57.685. + Executed 69 tests, with 0 failures (0 unexpected) in 20.026 (20.046) seconds +Test Suite 'All tests' passed at 2026-11-03 12:50:57.686. + Executed 69 tests, with 0 failures (0 unexpected) in 20.026 (20.047) seconds +2026-11-03 12:51:01.734 xcodebuild[4533:82488] [MT] IDETestOperationsObserverDebug: 25.176 elapsed -- Testing started completed. +2026-11-03 12:51:01.734 xcodebuild[4533:82488] [MT] IDETestOperationsObserverDebug: 0.000 sec, +0.000 sec -- start +2026-11-03 12:51:01.734 xcodebuild[4533:82488] [MT] IDETestOperationsObserverDebug: 25.176 sec, +25.176 sec -- end + +Test session results, code coverage, and logs: + /Users/markgregory/Developer/Applications/KJamsClone/build/Logs/Test/Test-KJMix Pro-2026.11.03_12-50-33-+0000.xcresult + +** TEST SUCCEEDED ** + +Testing started