feat(*): subsonic client with avfoundation playback and apple music ui
This commit is contained in:
commit
e5bb559d7f
93 files changed
+7517
No files matched your search
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Xcode
|
||||||
|
build/
|
||||||
|
DerivedData/
|
||||||
|
xcuserdata/
|
||||||
|
*.xcuserstate
|
||||||
|
*.xcscmblueprint
|
||||||
|
*.xcresult
|
||||||
|
*.ipa
|
||||||
|
*.dSYM
|
||||||
|
*.dSYM.zip
|
||||||
|
|
||||||
|
# Swift Package Manager
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
|
||||||
|
# Signing: copy Config/Signing.example.xcconfig
|
||||||
|
Config/Signing.xcconfig
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
repos:
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: fmt
|
||||||
|
name: fmt
|
||||||
|
entry: make fmt
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
- id: check
|
||||||
|
name: check
|
||||||
|
entry: make check
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
--swiftversion 6.0
|
||||||
|
--exclude build,**/.build
|
||||||
|
--maxwidth 140
|
||||||
|
--wraparguments before-first
|
||||||
|
--wrapcollections before-first
|
||||||
|
--self remove
|
||||||
|
--commas always
|
||||||
|
--disable wrapMultilineStatementBraces
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
included:
|
||||||
|
- Amphlify
|
||||||
|
- Packages
|
||||||
|
excluded:
|
||||||
|
- Packages/AmphlifyKit/.build
|
||||||
|
line_length:
|
||||||
|
warning: 140
|
||||||
|
ignores_comments: true
|
||||||
|
identifier_name:
|
||||||
|
excluded: [x, y, id, dx, dy, to, on]
|
||||||
|
type_body_length:
|
||||||
|
warning: 320
|
||||||
|
file_length:
|
||||||
|
warning: 450
|
||||||
|
nesting:
|
||||||
|
type_level: 2
|
||||||
|
opt_in_rules:
|
||||||
|
- empty_count
|
||||||
|
- first_where
|
||||||
|
- sorted_first_last
|
||||||
|
- toggle_bool
|
||||||
|
- redundant_nil_coalescing
|
||||||
|
disabled_rules:
|
||||||
|
- trailing_comma
|
||||||
|
- opening_brace
|
||||||
|
- todo
|
||||||
|
cyclomatic_complexity:
|
||||||
|
ignores_case_statements: true
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
A10000000000000000000100 /* Domain in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000101 /* Domain */; };
|
||||||
|
A10000000000000000000110 /* Store in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000111 /* Store */; };
|
||||||
|
A10000000000000000000120 /* Sync in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000121 /* Sync */; };
|
||||||
|
A10000000000000000000130 /* Playback in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000131 /* Playback */; };
|
||||||
|
A10000000000000000000140 /* SourceFake in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000141 /* SourceFake */; };
|
||||||
|
A10000000000000000000150 /* SourceSubsonic in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000151 /* SourceSubsonic */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
A10000000000000000000001 /* Amphlify.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Amphlify.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
A10000000000000000000030 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Base.xcconfig; path = Config/Base.xcconfig; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
A10000000000000000000002 /* Amphlify */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = Amphlify;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
A10000000000000000000003 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
A10000000000000000000100 /* Domain in Frameworks */,
|
||||||
|
A10000000000000000000110 /* Store in Frameworks */,
|
||||||
|
A10000000000000000000120 /* Sync in Frameworks */,
|
||||||
|
A10000000000000000000130 /* Playback in Frameworks */,
|
||||||
|
A10000000000000000000140 /* SourceFake in Frameworks */,
|
||||||
|
A10000000000000000000150 /* SourceSubsonic in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
A10000000000000000000004 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A10000000000000000000030 /* Base.xcconfig */,
|
||||||
|
A10000000000000000000002 /* Amphlify */,
|
||||||
|
A10000000000000000000005 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A10000000000000000000005 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A10000000000000000000001 /* Amphlify.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
A10000000000000000000006 /* Amphlify */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = A1000000000000000000000B /* Build configuration list for PBXNativeTarget "Amphlify" */;
|
||||||
|
buildPhases = (
|
||||||
|
A10000000000000000000007 /* Sources */,
|
||||||
|
A10000000000000000000003 /* Frameworks */,
|
||||||
|
A10000000000000000000008 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
A10000000000000000000002 /* Amphlify */,
|
||||||
|
);
|
||||||
|
name = Amphlify;
|
||||||
|
packageProductDependencies = (
|
||||||
|
A10000000000000000000101 /* Domain */,
|
||||||
|
A10000000000000000000111 /* Store */,
|
||||||
|
A10000000000000000000121 /* Sync */,
|
||||||
|
A10000000000000000000131 /* Playback */,
|
||||||
|
A10000000000000000000141 /* SourceFake */,
|
||||||
|
A10000000000000000000151 /* SourceSubsonic */,
|
||||||
|
);
|
||||||
|
productName = Amphlify;
|
||||||
|
productReference = A10000000000000000000001 /* Amphlify.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
A10000000000000000000009 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 2700;
|
||||||
|
LastUpgradeCheck = 2700;
|
||||||
|
TargetAttributes = {
|
||||||
|
A10000000000000000000006 = {
|
||||||
|
CreatedOnToolsVersion = 27.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = A1000000000000000000000A /* Build configuration list for PBXProject "Amphlify" */;
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
ru,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = A10000000000000000000004;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
packageReferences = (
|
||||||
|
A10000000000000000000200 /* XCLocalSwiftPackageReference "Packages/AmphlifyKit" */,
|
||||||
|
);
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = A10000000000000000000005 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
A10000000000000000000006 /* Amphlify */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
A10000000000000000000008 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
A10000000000000000000007 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
A1000000000000000000000C /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = A10000000000000000000030 /* Base.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
A1000000000000000000000D /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = A10000000000000000000030 /* Base.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
A1000000000000000000000E /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Amphlify;
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).amphlify";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 6.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
A1000000000000000000000F /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = Config/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Amphlify;
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).amphlify";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 6.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
A1000000000000000000000A /* Build configuration list for PBXProject "Amphlify" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
A1000000000000000000000C /* Debug */,
|
||||||
|
A1000000000000000000000D /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
A1000000000000000000000B /* Build configuration list for PBXNativeTarget "Amphlify" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
A1000000000000000000000E /* Debug */,
|
||||||
|
A1000000000000000000000F /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
A10000000000000000000200 /* XCLocalSwiftPackageReference "Packages/AmphlifyKit" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = Packages/AmphlifyKit;
|
||||||
|
};
|
||||||
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
A10000000000000000000101 /* Domain */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Domain;
|
||||||
|
};
|
||||||
|
A10000000000000000000111 /* Store */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Store;
|
||||||
|
};
|
||||||
|
A10000000000000000000121 /* Sync */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Sync;
|
||||||
|
};
|
||||||
|
A10000000000000000000131 /* Playback */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Playback;
|
||||||
|
};
|
||||||
|
A10000000000000000000141 /* SourceFake */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = SourceFake;
|
||||||
|
};
|
||||||
|
A10000000000000000000151 /* SourceSubsonic */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = SourceSubsonic;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
|
};
|
||||||
|
rootObject = A10000000000000000000009 /* Project object */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme LastUpgradeVersion = "2700" version = "1.7">
|
||||||
|
<BuildAction parallelizeBuildables = "YES" buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry buildForTesting = "YES" buildForRunning = "YES" buildForProfiling = "YES" buildForArchiving = "YES" buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "A10000000000000000000006" BuildableName = "Amphlify.app" BlueprintName = "Amphlify" ReferencedContainer = "container:Amphlify.xcodeproj"></BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "A10000000000000000000006" BuildableName = "Amphlify.app" BlueprintName = "Amphlify" ReferencedContainer = "container:Amphlify.xcodeproj"></BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction buildConfiguration = "Release" shouldUseLaunchSchemeArgsEnv = "YES" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "A10000000000000000000006" BuildableName = "Amphlify.app" BlueprintName = "Amphlify" ReferencedContainer = "container:Amphlify.xcodeproj"></BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction buildConfiguration = "Debug"></AnalyzeAction>
|
||||||
|
<ArchiveAction buildConfiguration = "Release" revealArchiveInOrganizer = "YES"></ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
struct ServerAccount: Identifiable, Hashable, Codable {
|
||||||
|
var id = UUID()
|
||||||
|
var name: String
|
||||||
|
var baseURL: URL
|
||||||
|
var username: String
|
||||||
|
|
||||||
|
static func normalize(_ text: String) -> URL? {
|
||||||
|
var trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
while trimmed.hasSuffix("/") {
|
||||||
|
trimmed.removeLast()
|
||||||
|
}
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
if !trimmed.contains("://") {
|
||||||
|
trimmed = "https://" + trimmed
|
||||||
|
}
|
||||||
|
guard let url = URL(string: trimmed), url.host() != nil else { return nil }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class AccountStore {
|
||||||
|
private(set) var accounts: [ServerAccount] = []
|
||||||
|
private let file: URL
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let support = try? FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
let folder = (support ?? URL.temporaryDirectory).appending(path: "Amphlify", directoryHint: .isDirectory)
|
||||||
|
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
file = folder.appending(path: "accounts.json")
|
||||||
|
accounts = (try? Data(contentsOf: file)).flatMap { try? JSONDecoder().decode([ServerAccount].self, from: $0) } ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func account(for id: UUID) -> ServerAccount? {
|
||||||
|
accounts.first { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func password(for id: UUID) -> String? {
|
||||||
|
Keychain.read(id.uuidString)
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(_ account: ServerAccount, password: String) throws {
|
||||||
|
try Keychain.write(password, for: account.id.uuidString)
|
||||||
|
if let index = accounts.firstIndex(where: { $0.id == account.id }) {
|
||||||
|
accounts[index] = account
|
||||||
|
} else {
|
||||||
|
accounts.append(account)
|
||||||
|
}
|
||||||
|
try persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(_ id: UUID) {
|
||||||
|
Keychain.delete(id.uuidString)
|
||||||
|
accounts.removeAll { $0.id == id }
|
||||||
|
try? persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persist() throws {
|
||||||
|
try JSONEncoder().encode(accounts).write(to: file, options: .atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Keychain {
|
||||||
|
static let service = "com.soundoz.amphlify"
|
||||||
|
|
||||||
|
static func read(_ account: String) -> String? {
|
||||||
|
var query = base(account)
|
||||||
|
query[kSecReturnData] = true
|
||||||
|
query[kSecMatchLimit] = kSecMatchLimitOne
|
||||||
|
var result: CFTypeRef?
|
||||||
|
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func write(_ value: String, for account: String) throws {
|
||||||
|
let data = Data(value.utf8)
|
||||||
|
let update = SecItemUpdate(base(account) as CFDictionary, [kSecValueData: data] as CFDictionary)
|
||||||
|
if update == errSecItemNotFound {
|
||||||
|
var query = base(account)
|
||||||
|
query[kSecValueData] = data
|
||||||
|
query[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlock
|
||||||
|
try check(SecItemAdd(query as CFDictionary, nil))
|
||||||
|
} else {
|
||||||
|
try check(update)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func delete(_ account: String) {
|
||||||
|
SecItemDelete(base(account) as CFDictionary)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func base(_ account: String) -> [CFString: Any] {
|
||||||
|
[kSecClass: kSecClassGenericPassword, kSecAttrService: service, kSecAttrAccount: account]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func check(_ status: OSStatus) throws {
|
||||||
|
guard status == errSecSuccess else {
|
||||||
|
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct AmphlifyApp: App {
|
||||||
|
@State private var app = Composition.makeApp()
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
RootView()
|
||||||
|
.environment(app)
|
||||||
|
.environment(app.player)
|
||||||
|
.environment(app.router)
|
||||||
|
.task { await app.start() }
|
||||||
|
.task(id: scenePhase) {
|
||||||
|
guard scenePhase == .active else { return }
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await app.resync()
|
||||||
|
try? await Task.sleep(for: .seconds(300))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if DEBUG
|
||||||
|
.task(id: app.revision == 0) {
|
||||||
|
if app.revision > 0 {
|
||||||
|
await DemoScene.requested?.apply(to: app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import Playback
|
||||||
|
import Store
|
||||||
|
import Sync
|
||||||
|
|
||||||
|
enum ConnectError: Error {
|
||||||
|
case unreachable, unauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
typealias Connector = (ServerAccount, String) async throws -> any LibrarySource
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class AppModel {
|
||||||
|
let library: any Library
|
||||||
|
let player: PlayerModel
|
||||||
|
let artwork: ArtworkLoader
|
||||||
|
let router = Router()
|
||||||
|
private(set) var sources: [any LibrarySource]
|
||||||
|
private(set) var revision = 0
|
||||||
|
private(set) var favorites: Set<EntityID> = []
|
||||||
|
private let registry: SourceRegistry
|
||||||
|
private let accounts: AccountStore
|
||||||
|
private let connect: Connector
|
||||||
|
private let sync: SyncEngine
|
||||||
|
private let remote = RemoteCommands()
|
||||||
|
private var isSyncing = false
|
||||||
|
|
||||||
|
init(
|
||||||
|
library: any Library,
|
||||||
|
registry: SourceRegistry,
|
||||||
|
accounts: AccountStore,
|
||||||
|
session: PlaybackSession,
|
||||||
|
connect: @escaping Connector
|
||||||
|
) {
|
||||||
|
self.library = library
|
||||||
|
self.registry = registry
|
||||||
|
self.accounts = accounts
|
||||||
|
self.connect = connect
|
||||||
|
sources = registry.sources
|
||||||
|
sync = SyncEngine(library: library)
|
||||||
|
artwork = ArtworkLoader(registry: registry)
|
||||||
|
player = PlayerModel(session: session, library: library, registry: registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasSources: Bool {
|
||||||
|
!sources.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
func account(for source: SourceID) -> ServerAccount? {
|
||||||
|
accounts.account(for: source.raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() async {
|
||||||
|
isSyncing = true
|
||||||
|
player.start()
|
||||||
|
remote.start(player: player, artwork: artwork)
|
||||||
|
let updates = await library.updates()
|
||||||
|
await syncAll()
|
||||||
|
#if DEBUG
|
||||||
|
if UserDefaults.standard.bool(forKey: "autoplay") {
|
||||||
|
await player.play(Array(library.tracks().prefix(5)))
|
||||||
|
router.showsNowPlaying = true
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
for await _ in updates {
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resync() async {
|
||||||
|
guard !isSyncing else { return }
|
||||||
|
isSyncing = true
|
||||||
|
await syncAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncAll() async {
|
||||||
|
for source in sources {
|
||||||
|
try? await sync.sync(source)
|
||||||
|
}
|
||||||
|
await refresh()
|
||||||
|
isSyncing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refresh() async {
|
||||||
|
favorites = await library.favorites()
|
||||||
|
revision += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(account: ServerAccount, password: String) async throws {
|
||||||
|
let source = try await connect(account, password)
|
||||||
|
try accounts.save(account, password: password)
|
||||||
|
registry.append(source)
|
||||||
|
sources.append(source)
|
||||||
|
try? await sync.sync(source)
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func signOut(_ account: ServerAccount) {
|
||||||
|
accounts.remove(account.id)
|
||||||
|
registry.remove(SourceID(account.id))
|
||||||
|
sources.removeAll { $0.id.raw == account.id }
|
||||||
|
player.send(.setQueue(tracks: [], startIndex: 0, shuffled: false))
|
||||||
|
revision += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func lyrics(for track: Track) async -> Lyrics? {
|
||||||
|
guard let source = registry.source(for: track.id.source) as? any LyricsSource else { return nil }
|
||||||
|
return try? await source.lyrics(for: track.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canFavorite(_ id: EntityID) -> Bool {
|
||||||
|
registry.source(for: id.source) is any Starring
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleFavorite(_ id: EntityID, kind: EntityKind) {
|
||||||
|
let isFavorite = !favorites.contains(id)
|
||||||
|
if isFavorite {
|
||||||
|
favorites.insert(id)
|
||||||
|
} else {
|
||||||
|
favorites.remove(id)
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
await library.setFavorite(isFavorite, for: id)
|
||||||
|
let source = registry.source(for: id.source) as? any Starring
|
||||||
|
try? await source?.setFavorite(isFavorite, for: id, kind: kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Domain
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class ArtworkLoader {
|
||||||
|
private let cache = NSCache<NSString, UIImage>()
|
||||||
|
private let registry: SourceRegistry
|
||||||
|
|
||||||
|
init(registry: SourceRegistry) {
|
||||||
|
self.registry = registry
|
||||||
|
cache.countLimit = 400
|
||||||
|
}
|
||||||
|
|
||||||
|
func cached(_ id: EntityID, pixelSize: Int) -> UIImage? {
|
||||||
|
cache.object(forKey: key(id, pixelSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
func image(_ id: EntityID, pixelSize: Int) async -> UIImage? {
|
||||||
|
if let hit = cached(id, pixelSize: pixelSize) {
|
||||||
|
return hit
|
||||||
|
}
|
||||||
|
guard let source = registry.source(for: id.source),
|
||||||
|
let request = try? await source.artwork(id, pixelSize: pixelSize),
|
||||||
|
let image = await Self.load(request)
|
||||||
|
else { return nil }
|
||||||
|
cache.setObject(image, forKey: key(id, pixelSize))
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
private func key(_ id: EntityID, _ pixelSize: Int) -> NSString {
|
||||||
|
"\(id.source.raw)/\(id.remote)/\(pixelSize)" as NSString
|
||||||
|
}
|
||||||
|
|
||||||
|
@concurrent
|
||||||
|
private nonisolated static func load(_ request: ResourceRequest) async -> UIImage? {
|
||||||
|
let data: Data?
|
||||||
|
if request.url.isFileURL {
|
||||||
|
data = try? Data(contentsOf: request.url)
|
||||||
|
} else {
|
||||||
|
var urlRequest = URLRequest(url: request.url)
|
||||||
|
request.headers.forEach { urlRequest.setValue($1, forHTTPHeaderField: $0) }
|
||||||
|
data = try? await URLSession.shared.data(for: urlRequest).0
|
||||||
|
}
|
||||||
|
return data.flatMap(UIImage.init(data:))?.preparingForDisplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
import Playback
|
||||||
|
import SourceFake
|
||||||
|
import SourceSubsonic
|
||||||
|
import Store
|
||||||
|
|
||||||
|
enum Composition {
|
||||||
|
static func makeApp() -> AppModel {
|
||||||
|
#if DEBUG
|
||||||
|
if DemoScene.requested != nil {
|
||||||
|
return makeDemo()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
let accounts = AccountStore()
|
||||||
|
#if DEBUG
|
||||||
|
LaunchAccount.apply(to: accounts)
|
||||||
|
#endif
|
||||||
|
let registry = SourceRegistry()
|
||||||
|
let engine = AVFoundationEngine { track in
|
||||||
|
guard let source = registry.source(for: track.id.source) else { throw SourceError.notFound }
|
||||||
|
return try await source.stream(for: track.id)
|
||||||
|
}
|
||||||
|
registry.replace(accounts.accounts.compactMap { account in
|
||||||
|
accounts.password(for: account.id).map { source(for: account, password: $0) }
|
||||||
|
})
|
||||||
|
return AppModel(
|
||||||
|
library: MemoryLibrary(),
|
||||||
|
registry: registry,
|
||||||
|
accounts: accounts,
|
||||||
|
session: PlaybackSession(engine: engine),
|
||||||
|
connect: connect
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func source(for account: ServerAccount, password: String) -> SubsonicSource {
|
||||||
|
SubsonicSource(
|
||||||
|
id: SourceID(account.id),
|
||||||
|
account: SubsonicAccount(baseURL: account.baseURL, username: account.username),
|
||||||
|
password: password,
|
||||||
|
name: account.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func connect(_ account: ServerAccount, password: String) async throws -> any LibrarySource {
|
||||||
|
let source = source(for: account, password: password)
|
||||||
|
do {
|
||||||
|
try await source.verify()
|
||||||
|
} catch is URLError {
|
||||||
|
throw ConnectError.unreachable
|
||||||
|
} catch SubsonicError.server(code: 40, message: _), SubsonicError.server(code: 41, message: _), SubsonicError.http(401) {
|
||||||
|
throw ConnectError.unauthorized
|
||||||
|
}
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
private static func makeDemo() -> AppModel {
|
||||||
|
let source = FakeSource()
|
||||||
|
ReferenceTrack.install(into: source)
|
||||||
|
let registry = SourceRegistry()
|
||||||
|
registry.replace([source])
|
||||||
|
return AppModel(
|
||||||
|
library: MemoryLibrary(),
|
||||||
|
registry: registry,
|
||||||
|
accounts: AccountStore(),
|
||||||
|
session: PlaybackSession(engine: SilentEngine()),
|
||||||
|
connect: connect
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum LaunchAccount {
|
||||||
|
static func apply(to store: AccountStore) {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
guard let server = defaults.string(forKey: "server"),
|
||||||
|
let url = ServerAccount.normalize(server),
|
||||||
|
let username = defaults.string(forKey: "user"),
|
||||||
|
let password = defaults.string(forKey: "password")
|
||||||
|
else { return }
|
||||||
|
let existing = store.accounts.first { $0.baseURL == url && $0.username == username }
|
||||||
|
let account = existing ?? ServerAccount(name: url.host() ?? server, baseURL: url, username: username)
|
||||||
|
try? store.save(account, password: password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#if DEBUG
|
||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum DemoScene: String {
|
||||||
|
case library, search, albums, artists, playlists, radio, album, artist, playlist, playing, queue, toggle, reference, mix, kassa,
|
||||||
|
lyrics
|
||||||
|
|
||||||
|
static var requested: DemoScene? {
|
||||||
|
UserDefaults.standard.string(forKey: "demo").flatMap(DemoScene.init)
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(to app: AppModel) async {
|
||||||
|
let albums = await app.library.albums(sortedBy: .artist)
|
||||||
|
guard let album = albums.dropFirst(11).first else { return }
|
||||||
|
let router = app.router
|
||||||
|
switch self {
|
||||||
|
case .library: router.tab = .library
|
||||||
|
case .search: router.tab = .search
|
||||||
|
case .albums: show(.section(.albums), in: router)
|
||||||
|
case .artists: show(.section(.artists), in: router)
|
||||||
|
case .playlists: show(.section(.playlists), in: router)
|
||||||
|
case .radio: router.tab = .radio
|
||||||
|
case .album: show(.album(album.id), in: router)
|
||||||
|
case .artist: album.artistID.map { show(.artist($0), in: router) }
|
||||||
|
case .playlist: await app.library.playlists().first.map { show(.playlist($0.id), in: router) }
|
||||||
|
case .mix:
|
||||||
|
guard let playlist = await app.library.playlists().first(where: { $0.name == "Reference" }) else { return }
|
||||||
|
await app.player.play(app.library.tracks(inPlaylist: playlist.id))
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
router.showsNowPlaying = true
|
||||||
|
case .kassa, .lyrics:
|
||||||
|
guard let track = await app.library.tracks().first(where: { $0.id.remote == ReferenceTrack.remote }) else { return }
|
||||||
|
app.player.play([track])
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
if self == .kassa {
|
||||||
|
app.player.send(.pause)
|
||||||
|
}
|
||||||
|
router.showsNowPlaying = true
|
||||||
|
router.showsLyrics = self == .lyrics
|
||||||
|
case .reference:
|
||||||
|
guard let track = await app.library.tracks().first(where: { $0.id.remote == ReferenceTrack.remote }) else { return }
|
||||||
|
let others = await app.library.tracks(inGenre: "Hip-Hop").filter { $0.id != track.id }.prefix(6)
|
||||||
|
app.player.play([track] + others)
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
router.showsNowPlaying = true
|
||||||
|
app.player.send(.seek(to: 90))
|
||||||
|
while true {
|
||||||
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
|
router.showsQueue.toggle()
|
||||||
|
}
|
||||||
|
case .playing, .queue, .toggle: await playAlbum(album, in: app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playAlbum(_ album: Album, in app: AppModel) async {
|
||||||
|
let router = app.router
|
||||||
|
show(.album(album.id), in: router)
|
||||||
|
await app.player.play(app.library.tracks(inAlbum: album.id), startingAt: 2)
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
router.showsNowPlaying = true
|
||||||
|
app.player.send(.seek(to: 47))
|
||||||
|
if self == .queue {
|
||||||
|
router.showsQueue = true
|
||||||
|
}
|
||||||
|
while self == .toggle {
|
||||||
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
|
router.showsQueue.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playReference(in app: AppModel) async {
|
||||||
|
guard let track = await app.library.tracks().first(where: { $0.id.remote == ReferenceTrack.remote }) else { return }
|
||||||
|
let router = app.router
|
||||||
|
let others = await app.library.tracks(inGenre: "Hip-Hop").filter { $0.id != track.id }.prefix(6)
|
||||||
|
app.player.play(self == .reference ? [track] + others : [track])
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
if self == .kassa {
|
||||||
|
app.player.send(.pause)
|
||||||
|
}
|
||||||
|
router.showsNowPlaying = true
|
||||||
|
router.showsLyrics = self == .lyrics
|
||||||
|
guard self == .reference else { return }
|
||||||
|
app.player.send(.seek(to: 90))
|
||||||
|
while true {
|
||||||
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
|
router.showsQueue.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func show(_ route: Route, in router: Router) {
|
||||||
|
router.tab = .library
|
||||||
|
router.paths[.library] = [route]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import Playback
|
||||||
|
import Store
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class PlayerModel {
|
||||||
|
private(set) var state = PlaybackState()
|
||||||
|
private let session: PlaybackSession
|
||||||
|
private let library: any Library
|
||||||
|
private let registry: SourceRegistry
|
||||||
|
private let scrobbleThreshold: TimeInterval = 240
|
||||||
|
|
||||||
|
init(session: PlaybackSession, library: any Library, registry: SourceRegistry) {
|
||||||
|
self.session = session
|
||||||
|
self.library = library
|
||||||
|
self.registry = registry
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
Task {
|
||||||
|
for await next in await session.states() {
|
||||||
|
let previous = state
|
||||||
|
state = next
|
||||||
|
guard next.current?.id != previous.current?.id else { continue }
|
||||||
|
if let finished = previous.current {
|
||||||
|
submit(finished, listened: previous.anchor.position(at: .now))
|
||||||
|
}
|
||||||
|
if let started = next.current {
|
||||||
|
announce(started.track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var current: Track? {
|
||||||
|
state.current?.track
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPlaying: Bool {
|
||||||
|
state.status == .playing
|
||||||
|
}
|
||||||
|
|
||||||
|
func position(at instant: ContinuousClock.Instant = .now) -> TimeInterval {
|
||||||
|
min(max(state.anchor.position(at: instant), 0), current?.duration ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(_ command: PlaybackCommand) {
|
||||||
|
Task { await session.send(command) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func play(_ tracks: [Track], startingAt index: Int = 0) {
|
||||||
|
send(.setQueue(tracks: tracks, startIndex: index, shuffled: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func shuffle(_ tracks: [Track]) {
|
||||||
|
send(.setQueue(tracks: tracks, startIndex: 0, shuffled: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCurrent(_ track: Track) -> Bool {
|
||||||
|
current?.id == track.id
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit(_ item: QueueItem, listened: TimeInterval) {
|
||||||
|
let duration = item.track.duration
|
||||||
|
let played = min(max(listened, 0), duration)
|
||||||
|
guard played > 0, played >= min(duration / 2, scrobbleThreshold) else { return }
|
||||||
|
scrobble(item.track.id, submission: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func announce(_ track: Track) {
|
||||||
|
Task { await library.recordPlay(of: track, at: Date()) }
|
||||||
|
scrobble(track.id, submission: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scrobble(_ id: EntityID, submission: Bool) {
|
||||||
|
guard let source = registry.source(for: id.source) as? any Scrobbling else { return }
|
||||||
|
Task { try? await source.scrobble(id, at: Date(), submission: submission) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#if DEBUG
|
||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
import SourceFake
|
||||||
|
|
||||||
|
enum ReferenceTrack {
|
||||||
|
static let remote = "reference-track"
|
||||||
|
|
||||||
|
static func install(into source: FakeSource) {
|
||||||
|
guard let cover = Bundle.main.url(forResource: "kassa", withExtension: "png"),
|
||||||
|
let lyrics = Bundle.main.url(forResource: "kassa", withExtension: "lrc")
|
||||||
|
else { return }
|
||||||
|
let artwork = EntityID(source: source.id, remote: "reference-art")
|
||||||
|
let artist = Artist(
|
||||||
|
id: EntityID(source: source.id, remote: "reference-artist"),
|
||||||
|
name: "рай не сегодня & 17 SEVENTEEN",
|
||||||
|
artwork: artwork
|
||||||
|
)
|
||||||
|
let album = Album(
|
||||||
|
id: EntityID(source: source.id, remote: "reference-album"),
|
||||||
|
title: "Касса - Single",
|
||||||
|
artistID: artist.id,
|
||||||
|
artistName: artist.name,
|
||||||
|
year: 2021,
|
||||||
|
genre: "Hip-Hop",
|
||||||
|
artwork: artwork,
|
||||||
|
trackCount: 1,
|
||||||
|
duration: 121,
|
||||||
|
addedAt: Date()
|
||||||
|
)
|
||||||
|
let track = Track(
|
||||||
|
id: EntityID(source: source.id, remote: remote),
|
||||||
|
title: "Касса",
|
||||||
|
albumID: album.id,
|
||||||
|
albumTitle: album.title,
|
||||||
|
artistID: artist.id,
|
||||||
|
artistName: artist.name,
|
||||||
|
trackNumber: 1,
|
||||||
|
duration: 121,
|
||||||
|
year: 2021,
|
||||||
|
genre: "Hip-Hop",
|
||||||
|
artwork: artwork,
|
||||||
|
isExplicit: true,
|
||||||
|
formats: [AudioFormat(codec: .mp3, bitrateKbps: 128, sampleRate: 44100)]
|
||||||
|
)
|
||||||
|
let filler = Array(source.sampleTracks.prefix(24))
|
||||||
|
source.add(changes(artist, album, track, filler: filler), artworkFiles: ["reference-art": cover], lyricsFiles: [remote: lyrics])
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func changes(_ artist: Artist, _ album: Album, _ track: Track, filler: [Track]) -> [Change] {
|
||||||
|
let playlist = Playlist(
|
||||||
|
id: EntityID(source: track.id.source, remote: "reference-playlist"),
|
||||||
|
name: "Reference",
|
||||||
|
artwork: track.artwork,
|
||||||
|
trackCount: filler.count + 1,
|
||||||
|
duration: track.duration + filler.reduce(0) { $0 + $1.duration },
|
||||||
|
updatedAt: Date()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
.upsert(.artist(artist)),
|
||||||
|
.upsert(.album(album)),
|
||||||
|
.upsert(.track(track)),
|
||||||
|
.upsert(.playlist(
|
||||||
|
playlist,
|
||||||
|
entries: ([track] + filler).enumerated().map { PlaylistEntry(id: "reference-\($0.offset)", track: $0.element.id) }
|
||||||
|
)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import Domain
|
||||||
|
import MediaPlayer
|
||||||
|
import Playback
|
||||||
|
|
||||||
|
final class RemoteCommands {
|
||||||
|
private let center = MPRemoteCommandCenter.shared()
|
||||||
|
private let info = MPNowPlayingInfoCenter.default()
|
||||||
|
private var artworkID: EntityID?
|
||||||
|
private var artwork: MPMediaItemArtwork?
|
||||||
|
private var artworkTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
func start(player: PlayerModel, artwork loader: ArtworkLoader) {
|
||||||
|
bind(center.playCommand, to: .play, on: player)
|
||||||
|
bind(center.pauseCommand, to: .pause, on: player)
|
||||||
|
bind(center.togglePlayPauseCommand, to: .togglePlayPause, on: player)
|
||||||
|
bind(center.nextTrackCommand, to: .next, on: player)
|
||||||
|
bind(center.previousTrackCommand, to: .previous, on: player)
|
||||||
|
center.changePlaybackPositionCommand.addTarget { event in
|
||||||
|
guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
||||||
|
player.send(.seek(to: event.positionTime))
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
for await state in Observations({ player.state }) {
|
||||||
|
update(state, loader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind(_ command: MPRemoteCommand, to playbackCommand: PlaybackCommand, on player: PlayerModel) {
|
||||||
|
command.addTarget { _ in
|
||||||
|
player.send(playbackCommand)
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func update(_ state: PlaybackState, _ loader: ArtworkLoader) {
|
||||||
|
guard let track = state.current?.track else {
|
||||||
|
info.nowPlayingInfo = nil
|
||||||
|
info.playbackState = .stopped
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadArtwork(track.artwork, loader)
|
||||||
|
let position = min(max(state.anchor.position(at: .now), 0), track.duration)
|
||||||
|
var dict: [String: Any] = [
|
||||||
|
MPMediaItemPropertyTitle: track.title,
|
||||||
|
MPMediaItemPropertyArtist: track.artistName,
|
||||||
|
MPMediaItemPropertyAlbumTitle: track.albumTitle,
|
||||||
|
MPMediaItemPropertyPlaybackDuration: track.duration,
|
||||||
|
MPNowPlayingInfoPropertyElapsedPlaybackTime: position,
|
||||||
|
MPNowPlayingInfoPropertyPlaybackRate: state.anchor.rate,
|
||||||
|
MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
|
||||||
|
]
|
||||||
|
dict[MPMediaItemPropertyArtwork] = artwork
|
||||||
|
info.nowPlayingInfo = dict
|
||||||
|
info.playbackState = state.status == .playing ? .playing : .paused
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadArtwork(_ id: EntityID?, _ loader: ArtworkLoader) {
|
||||||
|
guard id != artworkID else { return }
|
||||||
|
artworkID = id
|
||||||
|
artwork = nil
|
||||||
|
artworkTask?.cancel()
|
||||||
|
guard let id else { return }
|
||||||
|
artworkTask = Task {
|
||||||
|
guard let image = await loader.image(id, pixelSize: 600), !Task.isCancelled else { return }
|
||||||
|
artwork = MPMediaItemArtwork(boundsSize: image.size) { @Sendable _ in image }
|
||||||
|
info.nowPlayingInfo?[MPMediaItemPropertyArtwork] = artwork
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Domain
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
enum AppTab: Hashable {
|
||||||
|
case home, radio, library, search
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LibrarySection: Hashable, CaseIterable {
|
||||||
|
case playlists, artists, albums, songs, genres
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Route: Hashable {
|
||||||
|
case album(EntityID)
|
||||||
|
case artist(EntityID)
|
||||||
|
case playlist(EntityID)
|
||||||
|
case genre(String)
|
||||||
|
case favorites
|
||||||
|
case section(LibrarySection)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class Router {
|
||||||
|
var tab: AppTab = .home
|
||||||
|
var paths: [AppTab: [Route]] = [:]
|
||||||
|
var showsNowPlaying = false
|
||||||
|
var showsQueue = false {
|
||||||
|
didSet {
|
||||||
|
if showsQueue {
|
||||||
|
showsLyrics = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var showsLyrics = false {
|
||||||
|
didSet {
|
||||||
|
if showsLyrics {
|
||||||
|
showsQueue = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func path(for tab: AppTab) -> [Route] {
|
||||||
|
paths[tab] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func open(_ route: Route) {
|
||||||
|
showsNowPlaying = false
|
||||||
|
paths[tab, default: []].append(route)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Domain
|
||||||
|
import Synchronization
|
||||||
|
|
||||||
|
final nonisolated class SourceRegistry: Sendable {
|
||||||
|
private let storage = Mutex<[any LibrarySource]>([])
|
||||||
|
|
||||||
|
var sources: [any LibrarySource] {
|
||||||
|
storage.withLock { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
func replace(_ sources: [any LibrarySource]) {
|
||||||
|
storage.withLock { $0 = sources }
|
||||||
|
}
|
||||||
|
|
||||||
|
func append(_ source: any LibrarySource) {
|
||||||
|
storage.withLock { $0.append(source) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(_ id: SourceID) {
|
||||||
|
storage.withLock { list in list.removeAll { $0.id == id } }
|
||||||
|
}
|
||||||
|
|
||||||
|
func source(for id: SourceID) -> (any LibrarySource)? {
|
||||||
|
sources.first { $0.id == id }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{ "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { "red" : "0.980", "green" : "0.137", "blue" : "0.231", "alpha" : "1.000" } } },
|
||||||
|
{ "idiom" : "universal", "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "color" : { "color-space" : "srgb", "components" : { "red" : "0.980", "green" : "0.176", "blue" : "0.282", "alpha" : "1.000" } } }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "images" : [ { "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "info" : { "author" : "xcode", "version" : 1 } }
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage": "en",
|
||||||
|
"strings": {
|
||||||
|
"%lld songs, %@": {
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"variations": {
|
||||||
|
"plural": {
|
||||||
|
"one": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%lld song, %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%lld songs, %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"variations": {
|
||||||
|
"plural": {
|
||||||
|
"few": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%lld песни, %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"many": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%lld песен, %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"one": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%lld песня, %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%lld песни, %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Account": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Учётная запись"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Album · %@": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Альбом · %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Albums": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Альбомы"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Artist": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Артист"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Artist Stations": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Станции артистов"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Artists": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Артисты"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Artists, Songs, Albums and More": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Артисты, песни, альбомы и др."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Browse Categories": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Обзор категорий"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Can't connect to the server": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Не удается подключиться к серверу"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Can't sign in. Try again later.": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Не удается войти. Повторите попытку позже."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Done": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Готово"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Favorite": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "В избранное"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Favorite Songs": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Избранные песни"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Genres": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Жанры"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Go to Album": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Перейти к альбому"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Go to Artist": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Перейти к артисту"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Home": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Главная"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Library": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Медиатека"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Lyrics": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Текст песни"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"More": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Ещё"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"More by %@": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Ещё от: %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Most Played": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Часто слушаете"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Next": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Вперёд"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Not Playing": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Не исполняется"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Nothing up next": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Очередь пуста"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Now Playing": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Сейчас играет"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Password": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Пароль"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Pause": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Пауза"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Play": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Слушать"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Play Last": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Воспроизвести последней"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Play Next": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Воспроизвести далее"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Playing Next": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Далее"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Playlist": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Плейлист"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Playlists": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Плейлисты"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Previous": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Назад"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Queue": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Очередь"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Radio": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Радио"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Recently Added": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Недавно добавленные"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Recently Played": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Недавно прослушанные"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Repeat": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Повтор"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Search": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Поиск"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Server": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Сервер"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Shuffle": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Перемешать"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Sign In": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Войти"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Sign Out": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Выйти"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Songs": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Песни"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Sort": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Сортировка"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Sources": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Источники"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Station": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Станция"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Stations by Genre": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Станции по жанрам"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Title": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Название"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Top Picks for You": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Подборки для вас"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Top Songs": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Топ-песни"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Track position": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Позиция трека"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Undo Favorite": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Убрать из избранного"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Username": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Имя пользователя"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Volume": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Громкость"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Wrong username or password": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Неверное имя пользователя или пароль"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Year": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Год"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Your Playlists": {
|
||||||
|
"localizations": {
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Ваши плейлисты"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Domain
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ArtworkView: View {
|
||||||
|
enum Shape {
|
||||||
|
case rounded(CGFloat)
|
||||||
|
case circle
|
||||||
|
}
|
||||||
|
|
||||||
|
let artwork: EntityID?
|
||||||
|
var shape: Shape = .rounded(8)
|
||||||
|
var pixelSize = 320
|
||||||
|
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@State private var image: UIImage?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Color(.secondarySystemFill)
|
||||||
|
.aspectRatio(1, contentMode: .fit)
|
||||||
|
.overlay {
|
||||||
|
if let image = image ?? artwork.flatMap({ app.artwork.cached($0, pixelSize: pixelSize) }) {
|
||||||
|
Image(uiImage: image).resizable().scaledToFill()
|
||||||
|
} else {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
Image(systemName: "music.note")
|
||||||
|
.font(.system(size: proxy.size.width * 0.4))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipShape(clip)
|
||||||
|
.overlay { clip.strokeBorder(.primary.opacity(0.1), lineWidth: 0.5) }
|
||||||
|
.task(id: artwork) {
|
||||||
|
image = nil
|
||||||
|
guard let artwork else { return }
|
||||||
|
image = await app.artwork.image(artwork, pixelSize: pixelSize)
|
||||||
|
}
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var clip: RoundedRectangle {
|
||||||
|
switch shape {
|
||||||
|
case let .rounded(radius): RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||||
|
case .circle: RoundedRectangle(cornerRadius: 10000, style: .circular)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum Metrics {
|
||||||
|
static let margin: CGFloat = UIScreen.main.bounds.width >= 414 ? 20 : 16
|
||||||
|
static let tileSpacing: CGFloat = 12
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func shelfTile(fraction: CGFloat = 1 / 2.15) -> some View {
|
||||||
|
containerRelativeFrame(.horizontal) { width, _ in
|
||||||
|
(width - Metrics.margin * 2 - Metrics.tileSpacing) * fraction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountToolbar: ViewModifier {
|
||||||
|
@State private var showsAccount = false
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.toolbarTitleDisplayMode(.inlineLarge)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button { showsAccount = true } label: {
|
||||||
|
Text(app.sources.first?.name.prefix(1).uppercased() ?? "")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(Color(.systemIndigo).mix(with: .black, by: 0.45).gradient, in: .circle)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Account")
|
||||||
|
}
|
||||||
|
.sharedBackgroundVisibility(.hidden)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showsAccount) { AccountView() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Marquee: View {
|
||||||
|
let text: String
|
||||||
|
let font: Font
|
||||||
|
var restart = true
|
||||||
|
|
||||||
|
@State private var textWidth: CGFloat = 0
|
||||||
|
@State private var boxWidth: CGFloat = 0
|
||||||
|
@State private var startedAt = Date()
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
private let gap: CGFloat = 48
|
||||||
|
private let pause: TimeInterval = 2
|
||||||
|
private let speed: CGFloat = 30
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let overflows = textWidth > boxWidth + 1 && !reduceMotion
|
||||||
|
Text(text)
|
||||||
|
.font(font)
|
||||||
|
.lineLimit(1)
|
||||||
|
.hidden()
|
||||||
|
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { boxWidth = $0 }
|
||||||
|
.overlay(alignment: .leading) {
|
||||||
|
TimelineView(.animation(paused: !overflows)) { context in
|
||||||
|
HStack(spacing: gap) {
|
||||||
|
label.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { textWidth = $0 }
|
||||||
|
if overflows {
|
||||||
|
label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.offset(x: overflows ? -travel(at: context.date) : 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mask {
|
||||||
|
LinearGradient(
|
||||||
|
stops: [.init(color: .black, location: overflows ? 0.92 : 1), .init(color: overflows ? .clear : .black, location: 1)],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.clipped()
|
||||||
|
.onChange(of: "\(text)\(restart)", initial: true) { _, _ in
|
||||||
|
if restart {
|
||||||
|
startedAt = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func travel(at date: Date) -> CGFloat {
|
||||||
|
let loop = textWidth + gap
|
||||||
|
let elapsed = date.timeIntervalSince(startedAt)
|
||||||
|
let cycle = pause + Double(loop / speed)
|
||||||
|
let phase = max(elapsed.truncatingRemainder(dividingBy: cycle) - pause, 0)
|
||||||
|
return CGFloat(phase) * speed
|
||||||
|
}
|
||||||
|
|
||||||
|
private var label: some View {
|
||||||
|
Text(text).font(font).lineLimit(1).fixedSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import Domain
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CollectionHeader: View {
|
||||||
|
let artwork: EntityID?
|
||||||
|
let title: String
|
||||||
|
var subtitle: String?
|
||||||
|
var subtitleRoute: Route?
|
||||||
|
var meta: String?
|
||||||
|
let onPlay: () -> Void
|
||||||
|
let onShuffle: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ArtworkView(artwork: artwork, shape: .rounded(10), pixelSize: 1024)
|
||||||
|
.frame(maxWidth: 270)
|
||||||
|
.shadow(color: .black.opacity(0.22), radius: 22, y: 12)
|
||||||
|
.padding(.horizontal, 60)
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
Text(title)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
if let subtitle {
|
||||||
|
if let subtitleRoute {
|
||||||
|
NavigationLink(value: subtitleRoute) { Text(subtitle).font(.title3) }
|
||||||
|
} else {
|
||||||
|
Text(subtitle).font(.title3).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let meta {
|
||||||
|
Text(meta)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
PlayShuffleButtons(onPlay: onPlay, onShuffle: onShuffle)
|
||||||
|
.padding(.top, 16)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Metrics.margin)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlayShuffleButtons: View {
|
||||||
|
let onPlay: () -> Void
|
||||||
|
let onShuffle: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Button(action: onPlay) { Label("Play", systemImage: "play.fill").modifier(PillLabel()) }
|
||||||
|
Button(action: onShuffle) { Label("Shuffle", systemImage: "shuffle").modifier(PillLabel()) }
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PillLabel: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 50)
|
||||||
|
.background(Color(.secondarySystemFill), in: .capsule)
|
||||||
|
.contentShape(.capsule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScrollRevealedTitle: ViewModifier {
|
||||||
|
let title: String
|
||||||
|
var threshold: CGFloat = 300
|
||||||
|
@State private var isRevealed = false
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.onScrollGeometryChange(for: Bool.self) { $0.contentOffset.y + $0.contentInsets.top > threshold } action: { _, passed in
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) { isRevealed = passed }
|
||||||
|
}
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .principal) {
|
||||||
|
Text(title).font(.headline).lineLimit(1).opacity(isRevealed ? 1 : 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension TimeInterval {
|
||||||
|
var clock: String {
|
||||||
|
let total = Int(rounded(.down))
|
||||||
|
return total >= 3600
|
||||||
|
? String(format: "%d:%02d:%02d", total / 3600, total / 60 % 60, total % 60)
|
||||||
|
: String(format: "%d:%02d", total / 60, total % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
var spoken: String {
|
||||||
|
Duration.seconds(self < 60 ? self : (self / 60).rounded() * 60)
|
||||||
|
.formatted(.units(allowed: [.hours, .minutes, .seconds], width: .wide, maximumUnitCount: 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AudioFormat {
|
||||||
|
var badge: String {
|
||||||
|
if isHiRes {
|
||||||
|
return "Hi-Res Lossless"
|
||||||
|
}
|
||||||
|
if isLossless {
|
||||||
|
return "Lossless"
|
||||||
|
}
|
||||||
|
let name = codec.rawValue.uppercased()
|
||||||
|
return bitrateKbps.map { "\(name) \($0)" } ?? name
|
||||||
|
}
|
||||||
|
|
||||||
|
var detail: String? {
|
||||||
|
guard isLossless, let bitDepth, let sampleRate else { return nil }
|
||||||
|
let rate = Measurement(value: Double(sampleRate) / 1000, unit: UnitFrequency.kilohertz)
|
||||||
|
.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0 ... 1))))
|
||||||
|
return "\(bitDepth)-bit/\(rate) \(codec.rawValue.uppercased())"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct Shelf<Content: View>: View {
|
||||||
|
let title: LocalizedStringKey
|
||||||
|
var route: Route?
|
||||||
|
@ViewBuilder let content: Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
SectionHeader(title: title, route: route)
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
LazyHStack(alignment: .top, spacing: Metrics.tileSpacing) { content }
|
||||||
|
.scrollTargetLayout()
|
||||||
|
}
|
||||||
|
.scrollTargetBehavior(.viewAligned)
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
.contentMargins(.horizontal, Metrics.margin, for: .scrollContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SectionHeader: View {
|
||||||
|
let title: LocalizedStringKey
|
||||||
|
var route: Route?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let route {
|
||||||
|
NavigationLink(value: route) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
} else {
|
||||||
|
Text(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.title2.bold())
|
||||||
|
.padding(.horizontal, Metrics.margin)
|
||||||
|
.accessibilityAddTraits(.isHeader)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Domain
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AlbumTile: View {
|
||||||
|
let album: Album
|
||||||
|
var showsArtist = true
|
||||||
|
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@Environment(PlayerModel.self) private var player
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationLink(value: Route.album(album.id)) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
ArtworkView(artwork: album.artwork)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(album.title).foregroundStyle(.primary)
|
||||||
|
Text(showsArtist ? album.artistName : album.year.map(String.init) ?? " ").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
Button("Play", systemImage: "play") { load { player.play($0) } }
|
||||||
|
Button("Shuffle", systemImage: "shuffle") { load { player.shuffle($0) } }
|
||||||
|
Button("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") { load { player.send(.insertNext($0)) } }
|
||||||
|
Button("Play Last", systemImage: "text.line.last.and.arrowtriangle.forward") { load { player.send(.insertLast($0)) } }
|
||||||
|
if let artist = album.artistID {
|
||||||
|
Divider()
|
||||||
|
Button("Go to Artist", systemImage: "music.microphone") { app.router.open(.artist(artist)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load(_ action: @escaping ([Track]) -> Void) {
|
||||||
|
Task { await action(app.library.tracks(inAlbum: album.id)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlaylistTile: View {
|
||||||
|
let playlist: Playlist
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationLink(value: Route.playlist(playlist.id)) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
ArtworkView(artwork: playlist.artwork)
|
||||||
|
Text(playlist.name)
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(2, reservesSpace: true)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AlbumGrid: View {
|
||||||
|
let albums: [Album]
|
||||||
|
var showsArtist = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: Metrics.tileSpacing, alignment: .top)], spacing: 24) {
|
||||||
|
ForEach(albums) { AlbumTile(album: $0, showsArtist: showsArtist) }
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Metrics.margin)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import Domain
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TrackRow: View {
|
||||||
|
enum Subtitle {
|
||||||
|
case none, artist, album
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Leading {
|
||||||
|
case number
|
||||||
|
case artwork
|
||||||
|
}
|
||||||
|
|
||||||
|
let track: Track
|
||||||
|
var leading: Leading = .artwork
|
||||||
|
var subtitle: Subtitle = .artist
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@Environment(PlayerModel.self) private var player
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
lead
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(track.title).lineLimit(1)
|
||||||
|
if track.isExplicit {
|
||||||
|
Image(systemName: "e.square.fill").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if subtitle != .none {
|
||||||
|
Text(subtitle == .artist ? track.artistName : [track.albumTitle, track.year.map(String.init)].compactMap(\.self)
|
||||||
|
.joined(separator: " · "))
|
||||||
|
.font(.subheadline).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.contentShape(.rect)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
Menu { TrackMenu(track: track) } label: {
|
||||||
|
Image(systemName: "ellipsis")
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.contentShape(.rect)
|
||||||
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
.accessibilityLabel("More")
|
||||||
|
}
|
||||||
|
.padding(.leading, Metrics.margin)
|
||||||
|
.padding(.trailing, 8)
|
||||||
|
.frame(minHeight: leading == .artwork ? 60 : 48)
|
||||||
|
.contextMenu { TrackMenu(track: track) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var lead: some View {
|
||||||
|
let isCurrent = player.isCurrent(track)
|
||||||
|
switch leading {
|
||||||
|
case .number:
|
||||||
|
ZStack {
|
||||||
|
if isCurrent {
|
||||||
|
PlayingIndicator()
|
||||||
|
} else {
|
||||||
|
Text(track.trackNumber, format: .number).foregroundStyle(.secondary).monospacedDigit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 28)
|
||||||
|
.overlay(alignment: .leading) {
|
||||||
|
if app.favorites.contains(track.id) {
|
||||||
|
Image(systemName: "star.fill").font(.system(size: 8)).foregroundStyle(.tint).offset(x: -12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .artwork:
|
||||||
|
ArtworkView(artwork: track.artwork, shape: .rounded(5))
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
.overlay {
|
||||||
|
if isCurrent {
|
||||||
|
RoundedRectangle(cornerRadius: 5, style: .continuous).fill(.black.opacity(0.45))
|
||||||
|
PlayingIndicator().foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlayingIndicator: View {
|
||||||
|
@Environment(PlayerModel.self) private var player
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Image(systemName: "waveform")
|
||||||
|
.font(.subheadline.weight(.bold))
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
.symbolEffect(.variableColor.iterative.dimInactiveLayers, options: .repeating, isActive: player.isPlaying)
|
||||||
|
.accessibilityLabel("Now Playing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TrackMenu: View {
|
||||||
|
let track: Track
|
||||||
|
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@Environment(PlayerModel.self) private var player
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if app.canFavorite(track.id) {
|
||||||
|
let isFavorite = app.favorites.contains(track.id)
|
||||||
|
Button(isFavorite ? "Undo Favorite" as LocalizedStringKey : "Favorite", systemImage: isFavorite ? "star.slash" : "star") {
|
||||||
|
app.toggleFavorite(track.id, kind: .track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") { player.send(.insertNext([track])) }
|
||||||
|
Button("Play Last", systemImage: "text.line.last.and.arrowtriangle.forward") { player.send(.insertLast([track])) }
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
if let album = track.albumID {
|
||||||
|
Button { app.router.open(.album(album)) } label: {
|
||||||
|
Label("Go to Album", systemImage: "square.stack")
|
||||||
|
Text(track.albumTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let artist = track.artistID {
|
||||||
|
Button { app.router.open(.artist(artist)) } label: {
|
||||||
|
Label("Go to Artist", systemImage: "music.microphone")
|
||||||
|
Text(track.artistName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TrackList: View {
|
||||||
|
let tracks: [Track]
|
||||||
|
var leading: TrackRow.Leading = .artwork
|
||||||
|
var subtitle: TrackRow.Subtitle = .artist
|
||||||
|
|
||||||
|
@Environment(PlayerModel.self) private var player
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
ForEach(Array(tracks.enumerated()), id: \.offset) { index, track in
|
||||||
|
TrackRow(track: track, leading: leading, subtitle: subtitle) { player.play(tracks, startingAt: index) }
|
||||||
|
Divider().padding(.leading, Metrics.margin + (leading == .artwork ? 60 : 40))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import Domain
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AlbumDetailView: View {
|
||||||
|
let id: EntityID
|
||||||
|
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@Environment(PlayerModel.self) private var player
|
||||||
|
@State private var album: Album?
|
||||||
|
@State private var tracks: [Track] = []
|
||||||
|
@State private var more: [Album] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
if let album {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
CollectionHeader(
|
||||||
|
artwork: album.artwork,
|
||||||
|
title: album.title,
|
||||||
|
subtitle: album.artistName,
|
||||||
|
subtitleRoute: album.artistID.map(Route.artist),
|
||||||
|
meta: meta(album)
|
||||||
|
) { player.play(tracks) } onShuffle: { player.shuffle(tracks) }
|
||||||
|
Divider().padding(.leading, Metrics.margin)
|
||||||
|
TrackList(tracks: tracks, leading: .number, subtitle: .none)
|
||||||
|
footer(album)
|
||||||
|
if !more.isEmpty {
|
||||||
|
Shelf(title: "More by \(album.artistName)", route: album.artistID.map(Route.artist)) {
|
||||||
|
ForEach(more) { AlbumTile(album: $0, showsArtist: false).shelfTile() }
|
||||||
|
}
|
||||||
|
.padding(.vertical, 24)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modifier(ScrollRevealedTitle(title: album?.title ?? ""))
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu("More", systemImage: "ellipsis") {
|
||||||
|
Button("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward") { player.send(.insertNext(tracks)) }
|
||||||
|
Button("Play Last", systemImage: "text.line.last.and.arrowtriangle.forward") { player.send(.insertLast(tracks)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: app.revision) {
|
||||||
|
album = await app.library.album(id)
|
||||||
|
tracks = await app.library.tracks(inAlbum: id)
|
||||||
|
if let artist = album?.artistID {
|
||||||
|
more = await app.library.albums(byArtist: artist).filter { $0.id != id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func meta(_ album: Album) -> String {
|
||||||
|
[album.genre, album.year.map(String.init), album.bestFormat.flatMap { $0.isLossless ? $0.badge : nil }]
|
||||||
|
.compactMap(\.self)
|
||||||
|
.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func footer(_ album: Album) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(album.addedAt, format: .dateTime.day().month(.wide).year())
|
||||||
|
Text("\(tracks.count) songs, \(album.duration.spoken)")
|
||||||
|
if let detail = album.bestFormat?.detail {
|
||||||
|
Text(detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(Metrics.margin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlaylistDetailView: View {
|
||||||
|
enum Source: Hashable {
|
||||||
|
case playlist(EntityID)
|
||||||
|
case favorites
|
||||||
|
}
|
||||||
|
|
||||||
|
let source: Source
|
||||||
|
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@Environment(PlayerModel.self) private var player
|
||||||
|
@State private var playlist: Playlist?
|
||||||
|
@State private var tracks: [Track] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
CollectionHeader(
|
||||||
|
artwork: playlist?.artwork ?? (source == .favorites ? nil : tracks.first?.artwork),
|
||||||
|
title: title,
|
||||||
|
subtitle: playlist?.summary.isEmpty == false ? playlist?.summary : nil
|
||||||
|
) { player.play(tracks) } onShuffle: { player.shuffle(tracks) }
|
||||||
|
Divider().padding(.leading, Metrics.margin)
|
||||||
|
TrackList(tracks: tracks)
|
||||||
|
Text("\(tracks.count) songs, \(tracks.reduce(0) { $0 + $1.duration }.spoken)")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(Metrics.margin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modifier(ScrollRevealedTitle(title: title))
|
||||||
|
.task(id: app.revision) {
|
||||||
|
switch source {
|
||||||
|
case let .playlist(id):
|
||||||
|
playlist = await app.library.playlists().first { $0.id == id }
|
||||||
|
tracks = await app.library.tracks(inPlaylist: id)
|
||||||
|
case .favorites:
|
||||||
|
tracks = await app.library.favoriteTracks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var title: String {
|
||||||
|
source == .favorites ? String(localized: "Favorite Songs") : playlist?.name ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import Domain
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ArtistDetailView: View {
|
||||||
|
let id: EntityID
|
||||||
|
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@Environment(PlayerModel.self) private var player
|
||||||
|
@State private var artist: Artist?
|
||||||
|
@State private var albums: [Album] = []
|
||||||
|
@State private var topTracks: [Track] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
hero
|
||||||
|
if !topTracks.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
SectionHeader(title: "Top Songs")
|
||||||
|
TrackList(tracks: topTracks, subtitle: .album)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !albums.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
SectionHeader(title: "Albums")
|
||||||
|
AlbumGrid(albums: albums, showsArtist: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
|
.modifier(ScrollRevealedTitle(title: artist?.name ?? "", threshold: 270))
|
||||||
|
.scrollEdgeEffectHidden(true, for: .top)
|
||||||
|
.toolbar {
|
||||||
|
if app.canFavorite(id) {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
let isFavorite = app.favorites.contains(id)
|
||||||
|
Button(
|
||||||
|
isFavorite ? "Undo Favorite" as LocalizedStringKey : "Favorite",
|
||||||
|
systemImage: isFavorite ? "star.fill" : "star"
|
||||||
|
) {
|
||||||
|
app.toggleFavorite(id, kind: .artist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: app.revision) {
|
||||||
|
artist = await app.library.artist(id)
|
||||||
|
albums = await app.library.albums(byArtist: id)
|
||||||
|
topTracks = await Array(app.library.tracks(byArtist: id).prefix(5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hero: some View {
|
||||||
|
ArtworkView(artwork: artist?.artwork, shape: .rounded(0), pixelSize: 1024)
|
||||||
|
.containerRelativeFrame(.horizontal)
|
||||||
|
.visualEffect { content, proxy in
|
||||||
|
let pull = max(proxy.frame(in: .scrollView).minY, 0)
|
||||||
|
return content.scaleEffect(1 + pull / max(proxy.size.height, 1), anchor: .bottom)
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
LinearGradient(
|
||||||
|
stops: [
|
||||||
|
.init(color: .clear, location: 0),
|
||||||
|
.init(color: Color(.systemBackground).opacity(0.55), location: 0.55),
|
||||||
|
.init(color: Color(.systemBackground), location: 1),
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
.frame(height: 200)
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
HStack(alignment: .bottom) {
|
||||||
|
Text(artist?.name ?? "")
|
||||||
|
.font(.largeTitle.bold())
|
||||||
|
.lineLimit(2)
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
Task { await player.shuffle(app.library.tracks(byArtist: id)) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
.background(.tint, in: .circle)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Play")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Metrics.margin)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import Domain
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct HomeView: View {
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@State private var picks: [Album] = []
|
||||||
|
@State private var recentlyPlayed: [Album] = []
|
||||||
|
@State private var recentlyAdded: [Album] = []
|
||||||
|
@State private var mostPlayed: [Album] = []
|
||||||
|
@State private var playlists: [Playlist] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 32) {
|
||||||
|
if !picks.isEmpty {
|
||||||
|
Shelf(title: "Top Picks for You") {
|
||||||
|
ForEach(picks) { PickCard(album: $0).shelfTile(fraction: 0.71) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
albumShelf("Recently Played", recentlyPlayed)
|
||||||
|
albumShelf("Recently Added", recentlyAdded, route: .section(.albums))
|
||||||
|
if !playlists.isEmpty {
|
||||||
|
Shelf(title: "Your Playlists", route: .section(.playlists)) {
|
||||||
|
ForEach(playlists) { PlaylistTile(playlist: $0).shelfTile() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
albumShelf("Most Played", mostPlayed)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.navigationTitle("Home")
|
||||||
|
.modifier(AccountToolbar())
|
||||||
|
.task(id: app.revision) { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func albumShelf(_ title: LocalizedStringKey, _ albums: [Album], route: Route? = nil) -> some View {
|
||||||
|
if !albums.isEmpty {
|
||||||
|
Shelf(title: title, route: route) {
|
||||||
|
ForEach(albums) { AlbumTile(album: $0).shelfTile() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
let library = app.library
|
||||||
|
recentlyPlayed = await library.recentlyPlayed(limit: 12)
|
||||||
|
recentlyAdded = await Array(library.albums(sortedBy: .recentlyAdded).prefix(12))
|
||||||
|
mostPlayed = await library.mostPlayed(limit: 12)
|
||||||
|
playlists = await library.playlists()
|
||||||
|
if picks.isEmpty {
|
||||||
|
picks = await Array(library.albums(sortedBy: .year).shuffled().prefix(6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PickCard: View {
|
||||||
|
let album: Album
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationLink(value: Route.album(album.id)) {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
ArtworkView(artwork: album.artwork, shape: .rounded(0), pixelSize: 1024)
|
||||||
|
.mask { LinearGradient(colors: [.black, .black, .clear], startPoint: .center, endPoint: .bottom) }
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(album.genre ?? " ").opacity(0.75)
|
||||||
|
Text(album.title).fontWeight(.semibold)
|
||||||
|
Text(album.artistName).opacity(0.75)
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
}
|
||||||
|
.background {
|
||||||
|
ArtworkView(artwork: album.artwork, shape: .rounded(0))
|
||||||
|
.scaleEffect(x: 1, y: -1)
|
||||||
|
.blur(radius: 30)
|
||||||
|
.scaleEffect(1.4)
|
||||||
|
.overlay(.black.opacity(0.35))
|
||||||
|
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||||
|
}
|
||||||
|
.clipShape(.rect(cornerRadius: 16, style: .continuous))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountView: View {
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section("Sources") {
|
||||||
|
ForEach(app.sources, id: \.id) { source in
|
||||||
|
ServerRow(name: source.name, account: app.account(for: source.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Account")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done", systemImage: "checkmark") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ServerRow: View {
|
||||||
|
let name: String
|
||||||
|
let account: ServerAccount?
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Label {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(name)
|
||||||
|
if let account {
|
||||||
|
Text(account.username).font(.footnote).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "externaldrive.connected.to.line.below")
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if let account {
|
||||||
|
Button("Sign Out", role: .destructive) { app.signOut(account) }
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import Domain
|
||||||
|
import Store
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AlbumsView: View {
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@State private var albums: [Album] = []
|
||||||
|
@State private var sort: AlbumSort = .artist
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
AlbumGrid(albums: albums).padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.navigationTitle("Albums")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu("Sort", systemImage: "arrow.up.arrow.down") {
|
||||||
|
Picker("Sort", selection: $sort) {
|
||||||
|
Text("Artist").tag(AlbumSort.artist)
|
||||||
|
Text("Title").tag(AlbumSort.title)
|
||||||
|
Text("Year").tag(AlbumSort.year)
|
||||||
|
Text("Recently Added").tag(AlbumSort.recentlyAdded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: [app.revision, sort.hashValue]) { albums = await app.library.albums(sortedBy: sort) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ArtistsView: View {
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@State private var artists: [Artist] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List(artists) { artist in
|
||||||
|
NavigationLink(value: Route.artist(artist.id)) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
ArtworkView(artwork: artist.artwork, shape: .circle).frame(width: 52, height: 52)
|
||||||
|
Text(artist.name).font(.title3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alignmentGuide(.listRowSeparatorLeading) { $0[.leading] + 66 }
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle("Artists")
|
||||||
|
.task(id: app.revision) { artists = await app.library.artists() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SongsView: View {
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@Environment(PlayerModel.self) private var player
|
||||||
|
@State private var tracks: [Track] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
PlayShuffleButtons { player.play(tracks) } onShuffle: { player.shuffle(tracks) }
|
||||||
|
.padding(.horizontal, Metrics.margin)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
TrackList(tracks: tracks)
|
||||||
|
}
|
||||||
|
.navigationTitle("Songs")
|
||||||
|
.task(id: app.revision) { tracks = await app.library.tracks() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlaylistsView: View {
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@State private var playlists: [Playlist] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
NavigationLink(value: Route.favorites) {
|
||||||
|
row(Text("Favorite Songs")) {
|
||||||
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||||
|
.fill(Color.accentColor.gradient)
|
||||||
|
.overlay { Image(systemName: "star.fill").font(.title).foregroundStyle(.white) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listRowSeparator(.hidden, edges: .top)
|
||||||
|
.alignmentGuide(.listRowSeparatorLeading) { $0[.leading] + 86 }
|
||||||
|
ForEach(playlists) { playlist in
|
||||||
|
NavigationLink(value: Route.playlist(playlist.id)) {
|
||||||
|
row(Text(playlist.name)) { ArtworkView(artwork: playlist.artwork, shape: .rounded(6)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alignmentGuide(.listRowSeparatorLeading) { $0[.leading] + 86 }
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle("Playlists")
|
||||||
|
.task(id: app.revision) { playlists = await app.library.playlists() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ name: Text, @ViewBuilder artwork: () -> some View) -> some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
artwork().frame(width: 72, height: 72)
|
||||||
|
name.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GenresView: View {
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@State private var genres: [Genre] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List(genres) { genre in
|
||||||
|
NavigationLink(value: Route.genre(genre.name)) { Text(genre.name).font(.title3) }
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle("Genres")
|
||||||
|
.task(id: app.revision) { genres = await app.library.genres() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GenreDetailView: View {
|
||||||
|
let genre: String
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@State private var albums: [Album] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
AlbumGrid(albums: albums).padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.navigationTitle(genre)
|
||||||
|
.task(id: app.revision) { albums = await app.library.albums(inGenre: genre) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import Domain
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension LibrarySection {
|
||||||
|
var title: LocalizedStringKey {
|
||||||
|
switch self {
|
||||||
|
case .playlists: "Playlists"
|
||||||
|
case .artists: "Artists"
|
||||||
|
case .albums: "Albums"
|
||||||
|
case .songs: "Songs"
|
||||||
|
case .genres: "Genres"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .playlists: "music.note.list"
|
||||||
|
case .artists: "music.microphone"
|
||||||
|
case .albums: "square.stack"
|
||||||
|
case .songs: "music.note"
|
||||||
|
case .genres: "guitars"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LibraryView: View {
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@State private var recentlyAdded: [Album] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
|
ForEach(LibrarySection.allCases, id: \.self) { section in
|
||||||
|
NavigationLink(value: Route.section(section)) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: section.systemImage)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
.frame(width: 30)
|
||||||
|
Text(section.title).font(.title3)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.frame(minHeight: 52)
|
||||||
|
.contentShape(.rect)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, Metrics.margin)
|
||||||
|
Divider().padding(.leading, Metrics.margin + 44).padding(.trailing, Metrics.margin)
|
||||||
|
}
|
||||||
|
SectionHeader(title: "Recently Added")
|
||||||
|
.padding(.top, 28)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
AlbumGrid(albums: recentlyAdded)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
.navigationTitle("Library")
|
||||||
|
.modifier(AccountToolbar())
|
||||||
|
.task(id: app.revision) {
|
||||||
|
recentlyAdded = await Array(app.library.albums(sortedBy: .recentlyAdded).prefix(30))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import Domain
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct Backdrop: View {
|
||||||
|
let artwork: EntityID?
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
@State private var layers: [Layer] = []
|
||||||
|
@State private var drifts = false
|
||||||
|
|
||||||
|
private struct Layer: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let dark: Color
|
||||||
|
let light: Color
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color(white: 0.1)
|
||||||
|
ForEach(layers) { layer in
|
||||||
|
MeshGradient(
|
||||||
|
width: 3, height: 3,
|
||||||
|
points: [
|
||||||
|
[0, 0], [0.5, 0], [1, 0],
|
||||||
|
[0, 0.5], drifts ? [0.35, 0.6] : [0.65, 0.4], [1, 0.5],
|
||||||
|
[0, 1], [0.5, 1], [1, 1],
|
||||||
|
],
|
||||||
|
colors: [
|
||||||
|
layer.dark, layer.light, layer.light,
|
||||||
|
layer.dark, drifts ? layer.dark : layer.light, layer.light,
|
||||||
|
layer.dark, layer.dark, layer.dark,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.transition(.asymmetric(insertion: .opacity, removal: .identity))
|
||||||
|
}
|
||||||
|
LinearGradient(colors: [.clear, .black.opacity(0.15)], startPoint: .init(x: 0.5, y: 0.6), endPoint: .bottom)
|
||||||
|
}
|
||||||
|
.animation(.easeOut(duration: 0.35), value: layers.map(\.id))
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.task(id: artwork) {
|
||||||
|
guard let artwork, let image = await app.artwork.image(artwork, pixelSize: 128),
|
||||||
|
let (dark, light) = Palette.pair(of: image) else { return }
|
||||||
|
let layer = Layer(dark: dark, light: light)
|
||||||
|
layers.append(layer)
|
||||||
|
try? await Task.sleep(for: .seconds(0.5))
|
||||||
|
layers.removeAll { $0.id != layer.id }
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
guard !reduceMotion else { return }
|
||||||
|
withAnimation(.easeInOut(duration: 30).repeatForever(autoreverses: true)) { drifts = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Palette {
|
||||||
|
private static let side = 32
|
||||||
|
|
||||||
|
static func pair(of image: UIImage) -> (Color, Color)? {
|
||||||
|
guard let pixels = samples(of: image) else { return nil }
|
||||||
|
let mean = pixels.reduce(SIMD3<Double>()) { $0 + $1 } / Double(pixels.count)
|
||||||
|
let target = 0.42 * luminance(mean) + 22
|
||||||
|
var clusters = kmeans(pixels, count: 5)
|
||||||
|
.filter { luminance($0.center) > 25 && luminance($0.center) < 200 && saturation($0.center) > 0.15 }
|
||||||
|
.sorted { $0.weight > $1.weight }
|
||||||
|
.prefix(2)
|
||||||
|
.map(\.center)
|
||||||
|
if clusters.isEmpty {
|
||||||
|
clusters = [mean]
|
||||||
|
}
|
||||||
|
if clusters.count == 1 {
|
||||||
|
clusters.append(clusters[0] * 0.75)
|
||||||
|
}
|
||||||
|
let scale = target / max(luminance((clusters[0] + clusters[1]) / 2), 4)
|
||||||
|
let ordered = clusters.map { $0 * scale }.sorted { luminance($0) < luminance($1) }
|
||||||
|
return (color(ordered[0]), color(ordered[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Cluster {
|
||||||
|
var center: SIMD3<Double>
|
||||||
|
var weight: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func kmeans(_ pixels: [SIMD3<Double>], count: Int) -> [Cluster] {
|
||||||
|
var centers = (0 ..< count).map { pixels[$0 * pixels.count / count] }
|
||||||
|
var labels = [Int](repeating: 0, count: pixels.count)
|
||||||
|
for _ in 0 ..< 12 {
|
||||||
|
for (index, pixel) in pixels.enumerated() {
|
||||||
|
labels[index] = centers.indices.min { distance(pixel, centers[$0]) < distance(pixel, centers[$1]) } ?? 0
|
||||||
|
}
|
||||||
|
for cluster in centers.indices {
|
||||||
|
let members = pixels.indices.filter { labels[$0] == cluster }
|
||||||
|
guard !members.isEmpty else { continue }
|
||||||
|
centers[cluster] = members.reduce(SIMD3<Double>()) { $0 + pixels[$1] } / Double(members.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return centers.indices.map { cluster in
|
||||||
|
Cluster(center: centers[cluster], weight: Double(labels.filter { $0 == cluster }.count) / Double(pixels.count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func samples(of image: UIImage) -> [SIMD3<Double>]? {
|
||||||
|
guard let bitmap = image.cgImage, let srgb = CGColorSpace(name: CGColorSpace.sRGB),
|
||||||
|
let canvas = CGContext(
|
||||||
|
data: nil,
|
||||||
|
width: side,
|
||||||
|
height: side,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: side * 4,
|
||||||
|
space: srgb,
|
||||||
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||||
|
)
|
||||||
|
else { return nil }
|
||||||
|
canvas.interpolationQuality = .high
|
||||||
|
canvas.draw(bitmap, in: CGRect(x: 0, y: 0, width: side, height: side))
|
||||||
|
guard let data = canvas.data else { return nil }
|
||||||
|
let bytes = data.bindMemory(to: UInt8.self, capacity: side * side * 4)
|
||||||
|
return stride(from: 0, to: side * side * 4, by: 4).map {
|
||||||
|
SIMD3(Double(bytes[$0]), Double(bytes[$0 + 1]), Double(bytes[$0 + 2]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func luminance(_ rgb: SIMD3<Double>) -> Double {
|
||||||
|
0.299 * rgb.x + 0.587 * rgb.y + 0.114 * rgb.z
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func saturation(_ rgb: SIMD3<Double>) -> Double {
|
||||||
|
(rgb.max() - rgb.min()) / max(rgb.max(), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func distance(_ lhs: SIMD3<Double>, _ rhs: SIMD3<Double>) -> Double {
|
||||||
|
((lhs - rhs) * (lhs - rhs)).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func color(_ rgb: SIMD3<Double>) -> Color {
|
||||||
|
Color(red: rgb.x / 255, green: rgb.y / 255, blue: rgb.z / 255)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LyricsScrollSurface: View {
|
||||||
|
enum Phase {
|
||||||
|
case began, ended
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentHeight: CGFloat
|
||||||
|
let indicatorInset: CGFloat
|
||||||
|
let offset: CGFloat
|
||||||
|
let userDriven: Bool
|
||||||
|
let onPhase: (Phase, CGFloat) -> Void
|
||||||
|
let onScroll: (CGFloat) -> Void
|
||||||
|
let onTap: (CGPoint) -> Void
|
||||||
|
@State private var position = ScrollPosition()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
Color.clear
|
||||||
|
.frame(height: contentHeight)
|
||||||
|
.contentShape(.rect)
|
||||||
|
.onTapGesture(coordinateSpace: .named("surface")) { onTap($0) }
|
||||||
|
}
|
||||||
|
.coordinateSpace(.named("surface"))
|
||||||
|
.scrollPosition($position)
|
||||||
|
.scrollIndicators(.visible)
|
||||||
|
.contentMargins(.bottom, indicatorInset, for: .scrollIndicators)
|
||||||
|
.onScrollGeometryChange(for: CGFloat.self) { $0.contentOffset.y } action: { _, y in onScroll(y) }
|
||||||
|
.onScrollPhaseChange { old, new, context in
|
||||||
|
let y = context.geometry.contentOffset.y
|
||||||
|
if new == .tracking || (new == .interacting && old == .idle) {
|
||||||
|
onPhase(.began, y)
|
||||||
|
} else if new == .idle, old != .animating {
|
||||||
|
onPhase(.ended, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: offset, initial: true) {
|
||||||
|
guard !userDriven else { return }
|
||||||
|
position.scrollTo(y: offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import Domain
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LyricsView: View {
|
||||||
|
enum Intent {
|
||||||
|
case reveal, hide
|
||||||
|
}
|
||||||
|
|
||||||
|
let bottomInset: CGFloat
|
||||||
|
let controls: (Intent) -> Void
|
||||||
|
let seeks: Bool
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@Environment(PlayerModel.self) private var player
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
@State private var lyrics: Lyrics?
|
||||||
|
@State private var heights: [Int: CGFloat] = [:]
|
||||||
|
@State private var estimates: [CGFloat] = []
|
||||||
|
@State private var size: CGSize = .zero
|
||||||
|
@State private var scroll: Scroll?
|
||||||
|
@State private var pressed: Press?
|
||||||
|
@State private var seeked: Int?
|
||||||
|
|
||||||
|
private let anchor: CGFloat = 74
|
||||||
|
private let blockSpacing: CGFloat = 27
|
||||||
|
private let idleReturn: TimeInterval = 3.5
|
||||||
|
|
||||||
|
private struct Scroll {
|
||||||
|
struct Return {
|
||||||
|
let from: CGFloat
|
||||||
|
let began: Date
|
||||||
|
let response: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset: CGFloat
|
||||||
|
var dragStart: CGFloat
|
||||||
|
var travel: CGFloat = 0
|
||||||
|
var dragging = true
|
||||||
|
var idleSince: Date?
|
||||||
|
var back: Return?
|
||||||
|
|
||||||
|
func progress(at date: Date) -> Double {
|
||||||
|
guard let back else { return 0 }
|
||||||
|
return LyricsView.spring(date.timeIntervalSince(back.began), response: back.response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Press {
|
||||||
|
let index: Int
|
||||||
|
let began: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Frame {
|
||||||
|
let current: Int
|
||||||
|
let elapsed: TimeInterval
|
||||||
|
let position: TimeInterval
|
||||||
|
let date: Date
|
||||||
|
let tops: [CGFloat]
|
||||||
|
let follow: CGFloat
|
||||||
|
let contentOffset: CGFloat
|
||||||
|
let progress: Double
|
||||||
|
let uniform: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let lyrics {
|
||||||
|
synced(lyrics)
|
||||||
|
} else {
|
||||||
|
Text("No lyrics")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 }
|
||||||
|
.onChange(of: size.width) { remeasure() }
|
||||||
|
.task(id: player.current?.id) {
|
||||||
|
lyrics = nil
|
||||||
|
heights = [:]
|
||||||
|
scroll = nil
|
||||||
|
pressed = nil
|
||||||
|
seeked = nil
|
||||||
|
guard let track = player.current else { return }
|
||||||
|
lyrics = await app.lyrics(for: track)
|
||||||
|
remeasure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func synced(_ lyrics: Lyrics) -> some View {
|
||||||
|
let entries = entries(of: lyrics)
|
||||||
|
return TimelineView(.animation) { context in
|
||||||
|
let frame = frame(entries, date: context.date)
|
||||||
|
let idle = scroll?.idleSince.map { context.date.timeIntervalSince($0) > idleReturn } ?? false
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
Color.clear
|
||||||
|
ForEach(visible(entries, frame: frame), id: \.self) { index in
|
||||||
|
entry(entries[index], index: index, frame: frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
LyricsScrollSurface(
|
||||||
|
contentHeight: frame.tops[entries.count - 1] + size.height,
|
||||||
|
indicatorInset: bottomInset,
|
||||||
|
offset: frame.contentOffset,
|
||||||
|
userDriven: scroll?.back == nil && scroll != nil,
|
||||||
|
onPhase: { phase, offset in scrolled(phase, offset: offset) },
|
||||||
|
onScroll: { offset in scrolled(to: offset) },
|
||||||
|
onTap: { point in tapped(at: point, entries: entries) }
|
||||||
|
)
|
||||||
|
.padding(.horizontal, -32)
|
||||||
|
}
|
||||||
|
.onChange(of: idle) { _, idle in
|
||||||
|
if idle {
|
||||||
|
startReturn(response: 0.45)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: frame.progress >= 0.999) { _, done in
|
||||||
|
if done {
|
||||||
|
scroll = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mask {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
LinearGradient(colors: [.clear, .black], startPoint: .top, endPoint: .bottom).frame(height: 36)
|
||||||
|
Color.black
|
||||||
|
LinearGradient(colors: [.black, .clear], startPoint: .top, endPoint: .bottom).frame(height: 64)
|
||||||
|
Color.clear.frame(height: bottomInset)
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.1), value: bottomInset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func frame(_ entries: [Entry], date: Date) -> Frame {
|
||||||
|
let position = player.position(at: .now)
|
||||||
|
let current = entries.lastIndex { $0.time <= position + 0.03 } ?? 0
|
||||||
|
let elapsed = position - entries[current].time
|
||||||
|
var tops: [CGFloat] = []
|
||||||
|
var top: CGFloat = 0
|
||||||
|
for index in entries.indices {
|
||||||
|
tops.append(top)
|
||||||
|
top += height(index, entries: entries) + blockSpacing
|
||||||
|
}
|
||||||
|
let follow = followOffset(tops: tops, current: current, elapsed: elapsed)
|
||||||
|
let progress = scroll?.progress(at: date) ?? 1
|
||||||
|
let contentOffset: CGFloat = if let scroll {
|
||||||
|
scroll.back.map { $0.from + (follow - $0.from) * CGFloat(progress) } ?? scroll.offset
|
||||||
|
} else {
|
||||||
|
follow
|
||||||
|
}
|
||||||
|
return Frame(
|
||||||
|
current: current,
|
||||||
|
elapsed: elapsed,
|
||||||
|
position: position,
|
||||||
|
date: date,
|
||||||
|
tops: tops,
|
||||||
|
follow: follow,
|
||||||
|
contentOffset: contentOffset,
|
||||||
|
progress: progress,
|
||||||
|
uniform: scroll != nil && scroll?.back == nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func wave(tops: [CGFloat], current: Int, elapsed: TimeInterval) -> (start: CGFloat, delta: CGFloat)? {
|
||||||
|
guard current > 0, !reduceMotion, elapsed < 1.5, seeked != current else { return nil }
|
||||||
|
let start = tops[current - 1]
|
||||||
|
return (start, tops[current] - start)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func followOffset(tops: [CGFloat], current: Int, elapsed: TimeInterval) -> CGFloat {
|
||||||
|
guard let wave = wave(tops: tops, current: current, elapsed: elapsed) else { return tops[current] }
|
||||||
|
return wave.start + wave.delta * CGFloat(Self.spring(elapsed - 0.03, response: 0.65))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func residual(of index: Int, frame: Frame) -> CGFloat {
|
||||||
|
guard index > frame.current, let wave = wave(tops: frame.tops, current: frame.current, elapsed: frame.elapsed) else { return 0 }
|
||||||
|
let base = Self.spring(frame.elapsed - 0.03, response: 0.65)
|
||||||
|
let delayed = Self.spring(frame.elapsed - 0.03 - 0.033 * Double(index - frame.current), response: 0.65)
|
||||||
|
return wave.delta * CGFloat((base - delayed) * frame.progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func displayed(_ index: Int, frame: Frame) -> CGFloat {
|
||||||
|
anchor + frame.tops[index] - frame.contentOffset + residual(of: index, frame: frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func visible(_ entries: [Entry], frame: Frame) -> [Int] {
|
||||||
|
entries.indices.filter { index in
|
||||||
|
let top = displayed(index, frame: frame)
|
||||||
|
return top + height(index, entries: entries) > -160 && top < size.height + 160
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scrolled(_ phase: LyricsScrollSurface.Phase, offset: CGFloat) {
|
||||||
|
switch phase {
|
||||||
|
case .began:
|
||||||
|
scroll = Scroll(offset: offset, dragStart: offset)
|
||||||
|
pressed = nil
|
||||||
|
case .ended:
|
||||||
|
scroll?.dragging = false
|
||||||
|
scroll?.idleSince = .now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scrolled(to offset: CGFloat) {
|
||||||
|
guard var live = scroll, live.back == nil else { return }
|
||||||
|
let delta = offset - live.offset
|
||||||
|
live.travel = delta * live.travel >= 0 ? live.travel + delta : delta
|
||||||
|
live.offset = offset
|
||||||
|
scroll = live
|
||||||
|
if live.travel > 12 {
|
||||||
|
controls(.hide)
|
||||||
|
} else if live.travel < -12 {
|
||||||
|
controls(.reveal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startReturn(response: Double) {
|
||||||
|
guard let live = scroll, live.back == nil else { return }
|
||||||
|
scroll?.back = Scroll.Return(from: live.offset, began: .now, response: response)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tapped(at point: CGPoint, entries: [Entry]) {
|
||||||
|
controls(.reveal)
|
||||||
|
guard seeks else { return }
|
||||||
|
let frame = frame(entries, date: .now)
|
||||||
|
for index in entries.indices {
|
||||||
|
let top = displayed(index, frame: frame)
|
||||||
|
guard case let .line(line) = entries[index].kind,
|
||||||
|
(top - 8 ... top + height(index, entries: entries) + 8).contains(point.y) else { continue }
|
||||||
|
pressed = Press(index: index, began: .now)
|
||||||
|
seeked = index
|
||||||
|
var held = Scroll(offset: frame.contentOffset, dragStart: frame.contentOffset, dragging: false)
|
||||||
|
held.back = Scroll.Return(from: frame.contentOffset, began: .now + 0.12, response: 0.65)
|
||||||
|
scroll = held
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .milliseconds(120))
|
||||||
|
player.send(.seek(to: line.time))
|
||||||
|
player.send(.play)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func spring(_ elapsed: TimeInterval, response: Double) -> Double {
|
||||||
|
guard elapsed > 0 else { return 0 }
|
||||||
|
let omega = 2 * Double.pi / response
|
||||||
|
return 1 - (1 + omega * elapsed) * exp(-omega * elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func entry(_ entry: Entry, index: Int, frame: Frame) -> some View {
|
||||||
|
let state = LyricLineView.State(distance: index - frame.current, uniform: frame.uniform)
|
||||||
|
let press = pressAmount(index: index, date: frame.date)
|
||||||
|
return Group {
|
||||||
|
switch entry.kind {
|
||||||
|
case let .line(line):
|
||||||
|
LyricLineView(text: line.text, state: state, highlight: press.highlight)
|
||||||
|
case let .intro(until):
|
||||||
|
IntroDots(progress: min(max(frame.position / until, 0), 1), state: state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { heights[index] = $0 }
|
||||||
|
.offset(y: displayed(index, frame: frame) + press.shift)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pressAmount(index: Int, date: Date) -> (highlight: Double, shift: CGFloat) {
|
||||||
|
guard let pressed else { return (0, 0) }
|
||||||
|
let elapsed = date.timeIntervalSince(pressed.began)
|
||||||
|
guard elapsed < 0.55 else { return (0, 0) }
|
||||||
|
let highlight = elapsed < 0.05 ? elapsed / 0.05 : max(0, 1 - (elapsed - 0.15) / 0.4)
|
||||||
|
let push = elapsed < 0.12 ? elapsed / 0.12 : max(0, 1 - (elapsed - 0.12) / 0.3)
|
||||||
|
let eased = 1 - pow(1 - push, 2)
|
||||||
|
if index == pressed.index {
|
||||||
|
return (min(highlight, 1), CGFloat(eased) * 3)
|
||||||
|
}
|
||||||
|
return (0, CGFloat(eased) * (index < pressed.index ? -8 : 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func height(_ index: Int, entries: [Entry]) -> CGFloat {
|
||||||
|
if let measured = heights[index] {
|
||||||
|
return measured
|
||||||
|
}
|
||||||
|
return estimates.indices.contains(index) ? estimates[index] : estimate(entries[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func remeasure() {
|
||||||
|
guard let lyrics, size.width > 0 else { return }
|
||||||
|
estimates = entries(of: lyrics).map(estimate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func estimate(_ entry: Entry) -> CGFloat {
|
||||||
|
switch entry.kind {
|
||||||
|
case .intro:
|
||||||
|
return 39
|
||||||
|
case let .line(line):
|
||||||
|
let bounds = (line.text as NSString).boundingRect(
|
||||||
|
with: CGSize(width: max(size.width - 36, 1), height: .greatestFiniteMagnitude),
|
||||||
|
options: [.usesLineFragmentOrigin, .usesFontLeading],
|
||||||
|
attributes: [.font: LyricLineView.font],
|
||||||
|
context: nil
|
||||||
|
)
|
||||||
|
return ceil(bounds.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Entry {
|
||||||
|
enum Kind {
|
||||||
|
case line(LyricLine)
|
||||||
|
case intro(TimeInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
let time: TimeInterval
|
||||||
|
let kind: Kind
|
||||||
|
}
|
||||||
|
|
||||||
|
private func entries(of lyrics: Lyrics) -> [Entry] {
|
||||||
|
var out: [Entry] = []
|
||||||
|
if let first = lyrics.lines.first, first.time > 3 {
|
||||||
|
out.append(Entry(time: 0, kind: .intro(first.time)))
|
||||||
|
}
|
||||||
|
out += lyrics.lines.filter { !$0.text.isEmpty }.map { Entry(time: $0.time, kind: .line($0)) }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LyricLineView: View {
|
||||||
|
static let font = UIFont.systemFont(ofSize: 33, weight: .bold)
|
||||||
|
|
||||||
|
struct State: Equatable {
|
||||||
|
let distance: Int
|
||||||
|
let uniform: Bool
|
||||||
|
|
||||||
|
var opacity: Double {
|
||||||
|
if distance == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if uniform {
|
||||||
|
return 0.5
|
||||||
|
}
|
||||||
|
return distance < 0 ? 0.15 : max(0.38 - 0.04 * Double(distance - 1), 0.18)
|
||||||
|
}
|
||||||
|
|
||||||
|
var blur: CGFloat {
|
||||||
|
if distance == 0 || uniform {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return distance < 0 ? 3 : min(2 + CGFloat(distance - 1) * 0.8, 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let text: String
|
||||||
|
let state: State
|
||||||
|
let highlight: Double
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 33, weight: .bold))
|
||||||
|
.foregroundStyle(.white.opacity(state.opacity))
|
||||||
|
.blur(radius: state.blur)
|
||||||
|
.animation(.easeInOut(duration: state.uniform ? 0.12 : 0.25), value: state)
|
||||||
|
.padding(.leading, 2)
|
||||||
|
.padding(.trailing, 34)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.fill(.white.opacity(0.13 * highlight))
|
||||||
|
.padding(.vertical, -14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct IntroDots: View {
|
||||||
|
let progress: Double
|
||||||
|
let state: LyricLineView.State
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ForEach(0 ..< 3) { dot in
|
||||||
|
Circle()
|
||||||
|
.fill(.white.opacity(progress * 3 > Double(dot) + 0.5 ? 1 : 0.4))
|
||||||
|
.frame(width: 15, height: 15)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.opacity(state.uniform ? 0.5 : state.distance == 0 ? 1 : 0.3)
|
||||||
|
.blur(radius: state.blur)
|
||||||
|
.animation(.easeInOut(duration: 0.3), value: progress * 3 > 1.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MiniPlayer: View {
|
||||||
|
@Environment(PlayerModel.self) private var player
|
||||||
|
@Environment(Router.self) private var router
|
||||||
|
@Environment(\.tabViewBottomAccessoryPlacement) private var placement
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button {
|
||||||
|
router.showsNowPlaying = true
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ArtworkView(artwork: player.current?.artwork, shape: .rounded(7)).frame(width: 36, height: 36)
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(player.current?.title ?? String(localized: "Not Playing")).font(.subheadline.weight(.semibold))
|
||||||
|
if let artist = player.current?.artistName, placement != .inline {
|
||||||
|
Text(artist).font(.footnote).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.contentShape(.rect)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
Group {
|
||||||
|
Button(
|
||||||
|
player.isPlaying ? "Pause" as LocalizedStringKey : "Play",
|
||||||
|
systemImage: player.isPlaying ? "pause.fill" : "play.fill"
|
||||||
|
) {
|
||||||
|
player.send(.togglePlayPause)
|
||||||
|
}
|
||||||
|
.contentTransition(.symbolEffect(.replace))
|
||||||
|
if placement != .inline {
|
||||||
|
Button("Next", systemImage: "forward.fill") { player.send(.next) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.font(.title3)
|
||||||
|
.frame(width: 40, height: 44)
|
||||||
|
.tint(.primary)
|
||||||
|
.disabled(player.current == nil)
|
||||||
|
}
|
||||||
|
.padding(.leading, 12)
|
||||||
|
.padding(.trailing, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import Domain
|
||||||
|
import Playback
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct NowPlayingView: View {
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@Environment(PlayerModel.self) private var player
|
||||||
|
@Environment(Router.self) private var router
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
@State private var volume = 0.7
|
||||||
|
@State private var controlsHeight: CGFloat = 0
|
||||||
|
@State private var controlsRevealedAt = Date()
|
||||||
|
@State private var controlsForcedHidden = false
|
||||||
|
@State private var controlsHidden = false
|
||||||
|
|
||||||
|
private var hidesControls: Bool {
|
||||||
|
showsLyrics && (controlsForcedHidden || (player.isPlaying && Date().timeIntervalSince(controlsRevealedAt) > 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setControls(_ intent: LyricsView.Intent) {
|
||||||
|
controlsForcedHidden = intent == .hide
|
||||||
|
if intent == .reveal {
|
||||||
|
controlsRevealedAt = Date()
|
||||||
|
}
|
||||||
|
controlsHidden = hidesControls
|
||||||
|
}
|
||||||
|
|
||||||
|
private var showsQueue: Bool {
|
||||||
|
router.showsQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
private var showsLyrics: Bool {
|
||||||
|
router.showsLyrics
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isCompact: Bool {
|
||||||
|
showsQueue || showsLyrics
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Capsule().fill(.white.opacity(0.4)).frame(width: 36, height: 5).padding(.top, 8).padding(.bottom, 14)
|
||||||
|
GeometryReader { proxy in
|
||||||
|
let size = CGSize(width: proxy.size.width, height: proxy.size.height - controlsHeight)
|
||||||
|
stage(Stage(size: size, isCompact: isCompact, overflow: showsLyrics ? controlsHeight : 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controls(hidden: controlsHidden)
|
||||||
|
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { controlsHeight = $0 }
|
||||||
|
.allowsHitTesting(!controlsHidden)
|
||||||
|
}
|
||||||
|
.task(id: showsLyrics) {
|
||||||
|
while showsLyrics, !Task.isCancelled {
|
||||||
|
controlsHidden = hidesControls
|
||||||
|
try? await Task.sleep(for: .milliseconds(250))
|
||||||
|
}
|
||||||
|
controlsHidden = false
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
.frame(maxWidth: 560)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background { Backdrop(artwork: player.current?.artwork) }
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Stage {
|
||||||
|
let size: CGSize
|
||||||
|
let isCompact: Bool
|
||||||
|
let overflow: CGFloat
|
||||||
|
let titleHeight: CGFloat = 70
|
||||||
|
let compactSide: CGFloat = 72
|
||||||
|
let compactTop: CGFloat = 7
|
||||||
|
|
||||||
|
var fullSide: CGFloat {
|
||||||
|
max(min(size.width + 16, size.height - titleHeight - 16), compactSide)
|
||||||
|
}
|
||||||
|
|
||||||
|
var side: CGFloat {
|
||||||
|
isCompact ? compactSide : fullSide
|
||||||
|
}
|
||||||
|
|
||||||
|
var artworkX: CGFloat {
|
||||||
|
isCompact ? 0 : (size.width - fullSide) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
var artworkY: CGFloat {
|
||||||
|
isCompact ? compactTop : (size.height - titleHeight - fullSide) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
var titleY: CGFloat {
|
||||||
|
let full = size.height - titleHeight
|
||||||
|
guard isCompact else { return full }
|
||||||
|
let fullBottom = (size.height - titleHeight - fullSide) / 2 + fullSide
|
||||||
|
return full - 1.17 * (fullBottom - compactTop - compactSide)
|
||||||
|
}
|
||||||
|
|
||||||
|
var queueY: CGFloat {
|
||||||
|
artworkY + side + 12
|
||||||
|
}
|
||||||
|
|
||||||
|
var queueHeight: CGFloat {
|
||||||
|
max(size.height - compactTop - compactSide - 12, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lyricsHeight: CGFloat {
|
||||||
|
queueHeight + overflow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var move: Animation {
|
||||||
|
reduceMotion ? .easeInOut(duration: 0.2) : .spring(response: 0.305, dampingFraction: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stage(_ stage: Stage) -> some View {
|
||||||
|
let isCompact = stage.isCompact
|
||||||
|
let isShrunk = !isCompact && !player.isPlaying && !reduceMotion
|
||||||
|
return ZStack(alignment: .topLeading) {
|
||||||
|
Color.clear
|
||||||
|
.overlay(alignment: .topLeading) {
|
||||||
|
Group {
|
||||||
|
if showsLyrics {
|
||||||
|
LyricsView(bottomInset: controlsHidden ? 0 : stage.overflow, controls: setControls, seeks: !controlsHidden)
|
||||||
|
} else {
|
||||||
|
QueueView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: stage.size.width, height: showsLyrics ? stage.lyricsHeight : stage.queueHeight)
|
||||||
|
.offset(y: stage.queueY)
|
||||||
|
.animation(move, value: isCompact)
|
||||||
|
}
|
||||||
|
.clipped()
|
||||||
|
.opacity(isCompact ? 1 : 0)
|
||||||
|
.animation(isCompact ? .linear(duration: 0.16).delay(0.18) : .linear(duration: 0.115).delay(0.02), value: isCompact)
|
||||||
|
.allowsHitTesting(isCompact)
|
||||||
|
titleRow(isCompact: false)
|
||||||
|
.frame(width: stage.size.width)
|
||||||
|
.offset(y: stage.titleY)
|
||||||
|
.animation(move, value: isCompact)
|
||||||
|
.opacity(isCompact ? 0 : 1)
|
||||||
|
.animation(isCompact ? .linear(duration: 0.1).delay(0.04) : .easeInOut(duration: 0.16).delay(0.155), value: isCompact)
|
||||||
|
.allowsHitTesting(!isCompact)
|
||||||
|
titleRow(isCompact: true)
|
||||||
|
.frame(width: stage.size.width - stage.compactSide - 12, height: stage.compactSide)
|
||||||
|
.offset(x: stage.compactSide + 12, y: stage.compactTop)
|
||||||
|
.offset(y: isCompact ? 0 : 70)
|
||||||
|
.animation(isCompact ? move.delay(0.15) : nil, value: isCompact)
|
||||||
|
.opacity(isCompact ? 1 : 0)
|
||||||
|
.animation(isCompact ? .easeInOut(duration: 0.15).delay(0.215) : nil, value: isCompact)
|
||||||
|
.allowsHitTesting(isCompact)
|
||||||
|
ArtworkView(artwork: player.current?.artwork, shape: .rounded(isCompact ? 8 : 12), pixelSize: 1024)
|
||||||
|
.shadow(color: .black.opacity(player.isPlaying ? 0.3 : 0.15), radius: 20, y: 10)
|
||||||
|
.scaleEffect(isShrunk ? 0.73 : 1)
|
||||||
|
.animation(.spring(duration: 0.5, bounce: 0.3), value: player.isPlaying)
|
||||||
|
.frame(width: stage.side, height: stage.side)
|
||||||
|
.offset(x: stage.artworkX, y: stage.artworkY)
|
||||||
|
.animation(move, value: isCompact)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
.frame(width: stage.size.width, height: stage.size.height + stage.overflow, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func titles(isCompact: Bool) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Marquee(
|
||||||
|
text: player.current?.title ?? String(localized: "Not Playing"),
|
||||||
|
font: isCompact ? .headline : .title3.bold(),
|
||||||
|
restart: self.isCompact == isCompact
|
||||||
|
)
|
||||||
|
if player.current?.isExplicit == true {
|
||||||
|
Image(systemName: "e.square.fill")
|
||||||
|
.font(isCompact ? .caption : .footnote)
|
||||||
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Marquee(text: player.current?.artistName ?? " ", font: isCompact ? .subheadline : .title3, restart: self.isCompact == isCompact)
|
||||||
|
.foregroundStyle(.white.opacity(0.65))
|
||||||
|
.modifier(InvisibleMenu {
|
||||||
|
if let track = player.current {
|
||||||
|
TrackMenu(track: track)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func titleRow(isCompact: Bool) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
titles(isCompact: isCompact)
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var actions: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if let track = player.current {
|
||||||
|
if app.canFavorite(track.id) {
|
||||||
|
let isFavorite = app.favorites.contains(track.id)
|
||||||
|
Button(
|
||||||
|
isFavorite ? "Undo Favorite" as LocalizedStringKey : "Favorite",
|
||||||
|
systemImage: isFavorite ? "star.fill" : "star"
|
||||||
|
) {
|
||||||
|
app.toggleFavorite(track.id, kind: .track)
|
||||||
|
}
|
||||||
|
.modifier(RoundGlyph())
|
||||||
|
.symbolEffect(.bounce, value: isFavorite)
|
||||||
|
}
|
||||||
|
Label("More", systemImage: "ellipsis")
|
||||||
|
.modifier(RoundGlyph())
|
||||||
|
.modifier(InvisibleMenu { TrackMenu(track: track) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func controls(hidden: Bool) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Timeline()
|
||||||
|
.modifier(Fold(hidden: hidden, order: 0))
|
||||||
|
HStack(spacing: 34) {
|
||||||
|
Button("Previous", systemImage: "backward.fill") { player.send(.previous) }.font(.system(size: 32))
|
||||||
|
Button(
|
||||||
|
player.isPlaying ? "Pause" as LocalizedStringKey : "Play",
|
||||||
|
systemImage: player.isPlaying ? "pause.fill" : "play.fill"
|
||||||
|
) {
|
||||||
|
player.send(.togglePlayPause)
|
||||||
|
}
|
||||||
|
.font(.system(size: 46))
|
||||||
|
.contentTransition(.symbolEffect(.replace))
|
||||||
|
Button("Next", systemImage: "forward.fill") { player.send(.next) }.font(.system(size: 32))
|
||||||
|
}
|
||||||
|
.buttonStyle(TransportButtonStyle())
|
||||||
|
.disabled(player.current == nil)
|
||||||
|
.padding(.top, 31)
|
||||||
|
.padding(.bottom, 47)
|
||||||
|
.modifier(Fold(hidden: hidden, order: 1))
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "speaker.fill")
|
||||||
|
ScrubBar(value: volume, range: 0 ... 1) { volume = $0 } onCommit: { volume = $0 }
|
||||||
|
Image(systemName: "speaker.wave.3.fill")
|
||||||
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.white.opacity(0.65))
|
||||||
|
.accessibilityElement()
|
||||||
|
.accessibilityLabel("Volume")
|
||||||
|
.accessibilityValue(Text(volume, format: .percent.precision(.fractionLength(0))))
|
||||||
|
.modifier(Fold(hidden: hidden, order: 2))
|
||||||
|
HStack(spacing: 70) {
|
||||||
|
Button("Lyrics", systemImage: "quote.bubble") { router.showsLyrics.toggle() }
|
||||||
|
.modifier(ActiveGlyph(isActive: showsLyrics))
|
||||||
|
RoutePicker().frame(width: 44, height: 44)
|
||||||
|
Button("Queue", systemImage: "list.bullet") { router.showsQueue.toggle() }
|
||||||
|
.modifier(ActiveGlyph(isActive: showsQueue))
|
||||||
|
}
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.font(.title3)
|
||||||
|
.buttonStyle(GlyphButtonStyle())
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
.padding(.top, 14)
|
||||||
|
.padding(.bottom, 11)
|
||||||
|
.modifier(Fold(hidden: hidden, order: 3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Fold: ViewModifier {
|
||||||
|
let hidden: Bool
|
||||||
|
let order: Int
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.opacity(hidden ? 0 : 1)
|
||||||
|
.offset(y: hidden ? 110 : 0)
|
||||||
|
.animation(
|
||||||
|
hidden
|
||||||
|
? .easeIn(duration: 0.12).delay(0.06 * Double(order))
|
||||||
|
: .easeOut(duration: 0.12).delay(0.2 + 0.04 * Double(3 - order)),
|
||||||
|
value: hidden
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Timeline: View {
|
||||||
|
@Environment(PlayerModel.self) private var player
|
||||||
|
@State private var scrubbed: Double?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let duration = player.current?.duration ?? 0
|
||||||
|
TimelineView(.animation(minimumInterval: 0.2, paused: !player.isPlaying)) { _ in
|
||||||
|
let position = player.position()
|
||||||
|
VStack(spacing: -4) {
|
||||||
|
ScrubBar(value: position, range: 0 ... max(duration, 1)) { scrubbed = $0 } onCommit: {
|
||||||
|
scrubbed = nil
|
||||||
|
player.send(.seek(to: $0))
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text((scrubbed ?? position).clock)
|
||||||
|
Spacer()
|
||||||
|
if let format = player.current?.formats.first {
|
||||||
|
Label(format.badge, systemImage: "waveform")
|
||||||
|
.labelIconToTitleSpacing(4)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(.white.opacity(0.12), in: .rect(cornerRadius: 6, style: .continuous))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text("-" + max(duration - (scrubbed ?? position), 0).clock)
|
||||||
|
}
|
||||||
|
.font(.caption.weight(.semibold).monospacedDigit())
|
||||||
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement()
|
||||||
|
.accessibilityLabel("Track position")
|
||||||
|
.accessibilityValue(Text(player.position().clock))
|
||||||
|
.accessibilityAdjustableAction { direction in
|
||||||
|
player.send(.seek(to: player.position() + (direction == .increment ? 15 : -15)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct InvisibleMenu<Items: View>: ViewModifier {
|
||||||
|
@ViewBuilder let items: Items
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.overlay {
|
||||||
|
Menu { items } label: {
|
||||||
|
Color.clear.contentShape(.rect)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ActiveGlyph: ViewModifier {
|
||||||
|
let isActive: Bool
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.foregroundStyle(isActive ? Color.black.opacity(0.75) : .white.opacity(0.7))
|
||||||
|
.background {
|
||||||
|
if isActive {
|
||||||
|
Circle().fill(.white.opacity(0.6)).frame(width: 38, height: 38)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RoundGlyph: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.contentShape(.rect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TransportButtonStyle: ButtonStyle {
|
||||||
|
@Environment(\.isEnabled) private var isEnabled
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.foregroundStyle(.white.opacity(isEnabled ? 1 : 0.35))
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
.background(.white.opacity(configuration.isPressed ? 0.16 : 0), in: .circle)
|
||||||
|
.scaleEffect(configuration.isPressed ? 0.86 : 1)
|
||||||
|
.animation(.spring(duration: 0.25, bounce: 0.4), value: configuration.isPressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct GlyphButtonStyle: ButtonStyle {
|
||||||
|
@Environment(\.isEnabled) private var isEnabled
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.opacity(isEnabled ? (configuration.isPressed ? 0.5 : 1) : 0.35)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import Playback
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct QueueView: View {
|
||||||
|
@Environment(PlayerModel.self) private var player
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
toggle("Shuffle", "shuffle", isOn: player.state.isShuffled) { player.send(.setShuffle(!player.state.isShuffled)) }
|
||||||
|
toggle("Repeat", player.state.repeatMode == .one ? "repeat.1" : "repeat", isOn: player.state.repeatMode != .off) {
|
||||||
|
let modes = RepeatMode.allCases
|
||||||
|
let index = modes.firstIndex(of: player.state.repeatMode) ?? 0
|
||||||
|
player.send(.setRepeat(modes[(index + 1) % modes.count]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Playing Next")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.top, 18)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
List {
|
||||||
|
ForEach(player.state.upcoming) { item in
|
||||||
|
Button {
|
||||||
|
player.send(.jump(to: item.id))
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ArtworkView(artwork: item.track.artwork, shape: .rounded(5)).frame(width: 48, height: 48)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(item.track.title).foregroundStyle(.white)
|
||||||
|
Text(item.track.artistName).font(.subheadline).foregroundStyle(.white.opacity(0.6))
|
||||||
|
}
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Image(systemName: "line.3.horizontal").foregroundStyle(.white.opacity(0.3))
|
||||||
|
}
|
||||||
|
.contentShape(.rect)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
|
||||||
|
}
|
||||||
|
.onMove(perform: move)
|
||||||
|
.onDelete { offsets in
|
||||||
|
offsets.map { player.state.upcoming[$0].id }.forEach { player.send(.remove($0)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
|
.overlay {
|
||||||
|
if player.state.upcoming.isEmpty {
|
||||||
|
Text("Nothing up next").foregroundStyle(.white.opacity(0.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mask {
|
||||||
|
LinearGradient(
|
||||||
|
stops: [.init(color: .black, location: 0.9), .init(color: .clear, location: 1)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func move(from offsets: IndexSet, to destination: Int) {
|
||||||
|
let upcoming = player.state.upcoming
|
||||||
|
guard let from = offsets.first else { return }
|
||||||
|
var reordered = upcoming
|
||||||
|
reordered.move(fromOffsets: offsets, toOffset: destination)
|
||||||
|
guard let landed = reordered.firstIndex(where: { $0.id == upcoming[from].id }) else { return }
|
||||||
|
player.send(.move(item: upcoming[from].id, after: landed > 0 ? reordered[landed - 1].id : nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggle(_ title: LocalizedStringKey, _ systemImage: String, isOn: Bool, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Label(title, systemImage: systemImage)
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(isOn ? Color.black.opacity(0.8) : .white)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 38)
|
||||||
|
.background(.white.opacity(isOn ? 0.55 : 0.12), in: .capsule)
|
||||||
|
.contentShape(.rect)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityAddTraits(isOn ? .isSelected : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import AVKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RoutePicker: UIViewRepresentable {
|
||||||
|
func makeUIView(context _: Context) -> AVRoutePickerView {
|
||||||
|
let view = AVRoutePickerView()
|
||||||
|
view.tintColor = .white.withAlphaComponent(0.7)
|
||||||
|
view.activeTintColor = .white
|
||||||
|
view.prioritizesVideoDevices = false
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_: AVRoutePickerView, context _: Context) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ScrubBar: View {
|
||||||
|
let value: Double
|
||||||
|
let range: ClosedRange<Double>
|
||||||
|
var onScrub: (Double) -> Void = { _ in }
|
||||||
|
let onCommit: (Double) -> Void
|
||||||
|
|
||||||
|
@State private var dragStart: Double?
|
||||||
|
@State private var dragValue: Double?
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
|
var isScrubbing: Bool {
|
||||||
|
dragValue != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
let span = max(range.upperBound - range.lowerBound, .leastNonzeroMagnitude)
|
||||||
|
let fraction = ((dragValue ?? value) - range.lowerBound) / span
|
||||||
|
Capsule()
|
||||||
|
.fill(.white.opacity(0.25))
|
||||||
|
.overlay(alignment: .leading) {
|
||||||
|
Rectangle().fill(.white.opacity(isScrubbing ? 1 : 0.65)).frame(width: proxy.size.width * min(max(fraction, 0), 1))
|
||||||
|
}
|
||||||
|
.clipShape(.capsule)
|
||||||
|
.frame(height: isScrubbing ? 14 : 7)
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
.contentShape(.rect)
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 0)
|
||||||
|
.onChanged { drag in
|
||||||
|
let start = dragStart ?? value
|
||||||
|
dragStart = start
|
||||||
|
let next = start + drag.translation.width / proxy.size.width * span
|
||||||
|
dragValue = min(max(next, range.lowerBound), range.upperBound)
|
||||||
|
onScrub(dragValue ?? value)
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
if let dragValue {
|
||||||
|
onCommit(dragValue)
|
||||||
|
}
|
||||||
|
dragStart = nil
|
||||||
|
dragValue = nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(height: 30)
|
||||||
|
.scaleEffect(x: isScrubbing && !reduceMotion ? 1.03 : 1, y: 1)
|
||||||
|
.animation(.spring(duration: 0.3), value: isScrubbing)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import Domain
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RadioView: View {
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@Environment(PlayerModel.self) private var player
|
||||||
|
@State private var artists: [Artist] = []
|
||||||
|
@State private var genres: [Genre] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 32) {
|
||||||
|
if !artists.isEmpty {
|
||||||
|
Shelf(title: "Artist Stations") {
|
||||||
|
ForEach(artists) { artist in
|
||||||
|
StationTile(artwork: artist.artwork, title: artist.name, isCircle: true) {
|
||||||
|
await app.library.tracks(byArtist: artist.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !genres.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
SectionHeader(title: "Stations by Genre")
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 12) {
|
||||||
|
ForEach(Array(genres.enumerated()), id: \.element) { index, genre in
|
||||||
|
Button {
|
||||||
|
Task { await player.shuffle(app.library.tracks(inGenre: genre.name)) }
|
||||||
|
} label: {
|
||||||
|
GenreCard(name: genre.name, index: index)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Metrics.margin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.navigationTitle("Radio")
|
||||||
|
.modifier(AccountToolbar())
|
||||||
|
.task(id: app.revision) {
|
||||||
|
artists = await app.library.artists()
|
||||||
|
genres = await app.library.genres()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StationTile: View {
|
||||||
|
let artwork: EntityID?
|
||||||
|
let title: String
|
||||||
|
let isCircle: Bool
|
||||||
|
let tracks: () async -> [Track]
|
||||||
|
|
||||||
|
@Environment(PlayerModel.self) private var player
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
Task { await player.shuffle(tracks()) }
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ArtworkView(artwork: artwork, shape: isCircle ? .circle : .rounded(8))
|
||||||
|
VStack(spacing: 1) {
|
||||||
|
Text(title).lineLimit(1)
|
||||||
|
Text("Station").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
.shelfTile(fraction: 1 / 2.6)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GenreCard: View {
|
||||||
|
let name: String
|
||||||
|
let index: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let hue = (Double(index) * 0.618 + 0.95).truncatingRemainder(dividingBy: 1)
|
||||||
|
Text(name)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 104, alignment: .bottomLeading)
|
||||||
|
.background {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(hue: hue, saturation: 0.72, brightness: 0.82),
|
||||||
|
Color(hue: hue + 0.06, saturation: 0.85, brightness: 0.55),
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.clipShape(.rect(cornerRadius: 12, style: .continuous))
|
||||||
|
.contentShape(.rect)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RootView: View {
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@Environment(Router.self) private var router
|
||||||
|
@Namespace private var playerSpace
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if app.hasSources {
|
||||||
|
tabs
|
||||||
|
} else {
|
||||||
|
SignInView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tabs: some View {
|
||||||
|
@Bindable var router = router
|
||||||
|
return TabView(selection: $router.tab) {
|
||||||
|
Tab("Home", systemImage: "house.fill", value: AppTab.home) {
|
||||||
|
TabStack(tab: .home) { HomeView() }
|
||||||
|
}
|
||||||
|
Tab("Radio", systemImage: "dot.radiowaves.left.and.right", value: AppTab.radio) {
|
||||||
|
TabStack(tab: .radio) { RadioView() }
|
||||||
|
}
|
||||||
|
Tab("Library", systemImage: "music.note.square.stack.fill", value: AppTab.library) {
|
||||||
|
TabStack(tab: .library) { LibraryView() }
|
||||||
|
}
|
||||||
|
Tab("Search", systemImage: "magnifyingglass", value: AppTab.search) {
|
||||||
|
TabStack(tab: .search) { SearchView() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabBarMinimizeBehavior(.onScrollDown)
|
||||||
|
.tabViewBottomAccessory {
|
||||||
|
MiniPlayer()
|
||||||
|
.matchedTransitionSource(id: "player", in: playerSpace)
|
||||||
|
}
|
||||||
|
.fullScreenCover(isPresented: $router.showsNowPlaying) {
|
||||||
|
NowPlayingView()
|
||||||
|
.navigationTransition(.zoom(sourceID: "player", in: playerSpace))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TabStack<Content: View>: View {
|
||||||
|
let tab: AppTab
|
||||||
|
@ViewBuilder let content: Content
|
||||||
|
@Environment(Router.self) private var router
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack(path: Binding { router.path(for: tab) } set: { router.paths[tab] = $0 }) {
|
||||||
|
content.navigationDestination(for: Route.self) { route in
|
||||||
|
switch route {
|
||||||
|
case let .album(id): AlbumDetailView(id: id)
|
||||||
|
case let .artist(id): ArtistDetailView(id: id)
|
||||||
|
case let .playlist(id): PlaylistDetailView(source: .playlist(id))
|
||||||
|
case .favorites: PlaylistDetailView(source: .favorites)
|
||||||
|
case let .genre(name): GenreDetailView(genre: name)
|
||||||
|
case .section(.playlists): PlaylistsView()
|
||||||
|
case .section(.artists): ArtistsView()
|
||||||
|
case .section(.albums): AlbumsView()
|
||||||
|
case .section(.songs): SongsView()
|
||||||
|
case .section(.genres): GenresView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Domain
|
||||||
|
import Store
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SearchView: View {
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@Environment(PlayerModel.self) private var player
|
||||||
|
@State private var query = ""
|
||||||
|
@State private var results = SearchResults()
|
||||||
|
@State private var genres: [Genre] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
if query.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||||
|
browse
|
||||||
|
} else if results.isEmpty {
|
||||||
|
ContentUnavailableView.search(text: query).padding(.top, 80)
|
||||||
|
} else {
|
||||||
|
found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Search")
|
||||||
|
.modifier(AccountToolbar())
|
||||||
|
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Artists, Songs, Albums and More")
|
||||||
|
.task(id: query) {
|
||||||
|
results = await app.library.search(query)
|
||||||
|
}
|
||||||
|
.task(id: app.revision) { genres = await app.library.genres() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var browse: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
SectionHeader(title: "Browse Categories")
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 12) {
|
||||||
|
ForEach(Array(genres.enumerated()), id: \.element) { index, genre in
|
||||||
|
NavigationLink(value: Route.genre(genre.name)) { GenreCard(name: genre.name, index: index) }.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Metrics.margin)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var found: some View {
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
ForEach(results.artists) { artist in
|
||||||
|
link(.artist(artist.id), artwork: artist.artwork, isCircle: true, title: artist.name, subtitle: Text("Artist"))
|
||||||
|
}
|
||||||
|
ForEach(results.albums) { album in
|
||||||
|
link(.album(album.id), artwork: album.artwork, title: album.title, subtitle: Text("Album · \(album.artistName)"))
|
||||||
|
}
|
||||||
|
ForEach(results.playlists) { playlist in
|
||||||
|
link(.playlist(playlist.id), artwork: playlist.artwork, title: playlist.name, subtitle: Text("Playlist"))
|
||||||
|
}
|
||||||
|
ForEach(Array(results.tracks.enumerated()), id: \.offset) { index, track in
|
||||||
|
TrackRow(track: track) { player.play(results.tracks, startingAt: index) }
|
||||||
|
Divider().padding(.leading, Metrics.margin + 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func link(_ route: Route, artwork: EntityID?, isCircle: Bool = false, title: String, subtitle: Text) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
NavigationLink(value: route) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ArtworkView(artwork: artwork, shape: isCircle ? .circle : .rounded(5)).frame(width: 48, height: 48)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title).lineLimit(1)
|
||||||
|
subtitle.font(.subheadline).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Metrics.margin)
|
||||||
|
.frame(minHeight: 60)
|
||||||
|
.contentShape(.rect)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
Divider().padding(.leading, Metrics.margin + 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SignInView: View {
|
||||||
|
private enum Field {
|
||||||
|
case server, username, password
|
||||||
|
}
|
||||||
|
|
||||||
|
@Environment(AppModel.self) private var app
|
||||||
|
@State private var server = ""
|
||||||
|
@State private var username = ""
|
||||||
|
@State private var password = ""
|
||||||
|
@State private var isSigningIn = false
|
||||||
|
@State private var failure: LocalizedStringKey?
|
||||||
|
@FocusState private var focus: Field?
|
||||||
|
|
||||||
|
private var canSubmit: Bool {
|
||||||
|
!server.isEmpty && !username.isEmpty && !password.isEmpty && !isSigningIn
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField("Server", text: $server)
|
||||||
|
.textContentType(.URL)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.focused($focus, equals: .server)
|
||||||
|
.submitLabel(.next)
|
||||||
|
.onSubmit { focus = .username }
|
||||||
|
TextField("Username", text: $username)
|
||||||
|
.textContentType(.username)
|
||||||
|
.focused($focus, equals: .username)
|
||||||
|
.submitLabel(.next)
|
||||||
|
.onSubmit { focus = .password }
|
||||||
|
SecureField("Password", text: $password)
|
||||||
|
.textContentType(.password)
|
||||||
|
.focused($focus, equals: .password)
|
||||||
|
.submitLabel(.go)
|
||||||
|
.onSubmit(signIn)
|
||||||
|
} footer: {
|
||||||
|
if let failure {
|
||||||
|
Text(failure).foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
Section {
|
||||||
|
Button(action: signIn) {
|
||||||
|
ZStack {
|
||||||
|
Text("Sign In").fontWeight(.semibold).opacity(isSigningIn ? 0 : 1)
|
||||||
|
if isSigningIn {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.disabled(!canSubmit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Sign In")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func signIn() {
|
||||||
|
guard canSubmit else { return }
|
||||||
|
guard let url = ServerAccount.normalize(server) else {
|
||||||
|
failure = "Can't connect to the server"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let name = username.trimmingCharacters(in: .whitespaces)
|
||||||
|
let account = ServerAccount(name: url.host() ?? server, baseURL: url, username: name)
|
||||||
|
isSigningIn = true
|
||||||
|
failure = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await app.add(account: account, password: password)
|
||||||
|
} catch ConnectError.unreachable {
|
||||||
|
failure = "Can't connect to the server"
|
||||||
|
} catch ConnectError.unauthorized {
|
||||||
|
failure = "Wrong username or password"
|
||||||
|
} catch {
|
||||||
|
failure = "Can't sign in. Try again later."
|
||||||
|
}
|
||||||
|
isSigningIn = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
## Code Principles
|
||||||
|
- **Simplicity**: Write simple, straightforward code
|
||||||
|
- **Readability**: Make code easy to understand
|
||||||
|
- **Performance**: Consider performance without sacrificing readability
|
||||||
|
- **Maintainability**: Write code that's easy to update
|
||||||
|
- **Reusability**: Create reusable components and functions
|
||||||
|
- **Less Code = Less Debt**: Minimize code footprint
|
||||||
|
- **NEVER write comments** - code should be self-documenting
|
||||||
|
- **Docstrings ONLY if explicitly asked**
|
||||||
|
|
||||||
|
## Checking commands
|
||||||
|
After writing code, always run:
|
||||||
|
```shell
|
||||||
|
make fmt
|
||||||
|
make check
|
||||||
|
```
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
BUNDLE_ID_PREFIX = com.example
|
||||||
|
#include? "Signing.xcconfig"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DEVELOPMENT_TEAM = ABCDE12345
|
||||||
|
BUNDLE_ID_PREFIX = com.yourname
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
SCHEME := Amphlify
|
||||||
|
DESTINATION ?= platform=iOS Simulator,name=iPhone 18 Pro
|
||||||
|
XCODEBUILD := xcodebuild -project Amphlify.xcodeproj -scheme $(SCHEME) -derivedDataPath build -quiet
|
||||||
|
KIT := Packages/AmphlifyKit
|
||||||
|
|
||||||
|
.PHONY: fmt check test run
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
swiftformat .
|
||||||
|
|
||||||
|
check:
|
||||||
|
swiftformat --lint .
|
||||||
|
swiftlint --strict --quiet
|
||||||
|
$(XCODEBUILD) -destination 'generic/platform=iOS Simulator' build
|
||||||
|
|
||||||
|
test:
|
||||||
|
swift test --package-path $(KIT) --quiet
|
||||||
|
|
||||||
|
run:
|
||||||
|
$(XCODEBUILD) -destination '$(DESTINATION)' build
|
||||||
|
xcrun simctl install booted build/Build/Products/Debug-iphonesimulator/Amphlify.app
|
||||||
|
xcrun simctl launch booted com.soundoz.amphlify
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// swift-tools-version: 6.2
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "AmphlifyKit",
|
||||||
|
platforms: [.iOS(.v26), .macOS(.v26), .watchOS(.v26)],
|
||||||
|
products: [
|
||||||
|
.library(name: "Domain", targets: ["Domain"]),
|
||||||
|
.library(name: "Store", targets: ["Store"]),
|
||||||
|
.library(name: "Sync", targets: ["Sync"]),
|
||||||
|
.library(name: "Playback", targets: ["Playback"]),
|
||||||
|
.library(name: "SourceFake", targets: ["SourceFake"]),
|
||||||
|
.library(name: "SourceSubsonic", targets: ["SourceSubsonic"]),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(name: "Domain"),
|
||||||
|
.target(name: "Store", dependencies: ["Domain"]),
|
||||||
|
.target(name: "Sync", dependencies: ["Domain", "Store"]),
|
||||||
|
.target(name: "Playback", dependencies: ["Domain"]),
|
||||||
|
.target(name: "SourceFake", dependencies: ["Domain"]),
|
||||||
|
.target(name: "SourceSubsonic", dependencies: ["Domain"]),
|
||||||
|
.testTarget(name: "DomainTests", dependencies: ["Domain"]),
|
||||||
|
.testTarget(name: "PlaybackTests", dependencies: ["Playback"]),
|
||||||
|
.testTarget(name: "StoreTests", dependencies: ["Store", "Sync", "SourceFake"]),
|
||||||
|
.testTarget(name: "SourceSubsonicTests", dependencies: ["SourceSubsonic"], resources: [.copy("Fixtures")]),
|
||||||
|
],
|
||||||
|
swiftLanguageModes: [.v6]
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct AudioFormat: Hashable, Codable, Sendable {
|
||||||
|
public enum Codec: String, Codable, Sendable {
|
||||||
|
case flac, alac, wav, mp3, aac, opus, vorbis, eac3
|
||||||
|
}
|
||||||
|
|
||||||
|
public var codec: Codec
|
||||||
|
public var bitrateKbps: Int?
|
||||||
|
public var sampleRate: Int?
|
||||||
|
public var bitDepth: Int?
|
||||||
|
public var channels: Int
|
||||||
|
|
||||||
|
public init(codec: Codec, bitrateKbps: Int? = nil, sampleRate: Int? = nil, bitDepth: Int? = nil, channels: Int = 2) {
|
||||||
|
self.codec = codec
|
||||||
|
self.bitrateKbps = bitrateKbps
|
||||||
|
self.sampleRate = sampleRate
|
||||||
|
self.bitDepth = bitDepth
|
||||||
|
self.channels = channels
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isLossless: Bool {
|
||||||
|
switch codec {
|
||||||
|
case .flac, .alac, .wav: true
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isHiRes: Bool {
|
||||||
|
isLossless && ((sampleRate ?? 0) > 48000 || (bitDepth ?? 0) > 16 && (sampleRate ?? 0) > 44100)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct Artist: Identifiable, Hashable, Codable, Sendable {
|
||||||
|
public let id: EntityID
|
||||||
|
public var name: String
|
||||||
|
public var sortName: String
|
||||||
|
public var artwork: EntityID?
|
||||||
|
public var albumCount: Int
|
||||||
|
|
||||||
|
public init(id: EntityID, name: String, sortName: String? = nil, artwork: EntityID? = nil, albumCount: Int = 0) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.sortName = sortName ?? name
|
||||||
|
self.artwork = artwork
|
||||||
|
self.albumCount = albumCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Album: Identifiable, Hashable, Codable, Sendable {
|
||||||
|
public let id: EntityID
|
||||||
|
public var title: String
|
||||||
|
public var artistID: EntityID?
|
||||||
|
public var artistName: String
|
||||||
|
public var year: Int?
|
||||||
|
public var genre: String?
|
||||||
|
public var artwork: EntityID?
|
||||||
|
public var trackCount: Int
|
||||||
|
public var duration: TimeInterval
|
||||||
|
public var addedAt: Date
|
||||||
|
public var bestFormat: AudioFormat?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: EntityID,
|
||||||
|
title: String,
|
||||||
|
artistID: EntityID?,
|
||||||
|
artistName: String,
|
||||||
|
year: Int? = nil,
|
||||||
|
genre: String? = nil,
|
||||||
|
artwork: EntityID? = nil,
|
||||||
|
trackCount: Int = 0,
|
||||||
|
duration: TimeInterval = 0,
|
||||||
|
addedAt: Date = .distantPast,
|
||||||
|
bestFormat: AudioFormat? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.artistID = artistID
|
||||||
|
self.artistName = artistName
|
||||||
|
self.year = year
|
||||||
|
self.genre = genre
|
||||||
|
self.artwork = artwork
|
||||||
|
self.trackCount = trackCount
|
||||||
|
self.duration = duration
|
||||||
|
self.addedAt = addedAt
|
||||||
|
self.bestFormat = bestFormat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Track: Identifiable, Hashable, Codable, Sendable {
|
||||||
|
public let id: EntityID
|
||||||
|
public var title: String
|
||||||
|
public var albumID: EntityID?
|
||||||
|
public var albumTitle: String
|
||||||
|
public var artistID: EntityID?
|
||||||
|
public var artistName: String
|
||||||
|
public var discNumber: Int
|
||||||
|
public var trackNumber: Int
|
||||||
|
public var duration: TimeInterval
|
||||||
|
public var year: Int?
|
||||||
|
public var genre: String?
|
||||||
|
public var artwork: EntityID?
|
||||||
|
public var isExplicit: Bool
|
||||||
|
public var contentHash: Data?
|
||||||
|
public var formats: [AudioFormat]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: EntityID,
|
||||||
|
title: String,
|
||||||
|
albumID: EntityID?,
|
||||||
|
albumTitle: String,
|
||||||
|
artistID: EntityID?,
|
||||||
|
artistName: String,
|
||||||
|
discNumber: Int = 1,
|
||||||
|
trackNumber: Int = 0,
|
||||||
|
duration: TimeInterval,
|
||||||
|
year: Int? = nil,
|
||||||
|
genre: String? = nil,
|
||||||
|
artwork: EntityID? = nil,
|
||||||
|
isExplicit: Bool = false,
|
||||||
|
contentHash: Data? = nil,
|
||||||
|
formats: [AudioFormat] = []
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.albumID = albumID
|
||||||
|
self.albumTitle = albumTitle
|
||||||
|
self.artistID = artistID
|
||||||
|
self.artistName = artistName
|
||||||
|
self.discNumber = discNumber
|
||||||
|
self.trackNumber = trackNumber
|
||||||
|
self.duration = duration
|
||||||
|
self.year = year
|
||||||
|
self.genre = genre
|
||||||
|
self.artwork = artwork
|
||||||
|
self.isExplicit = isExplicit
|
||||||
|
self.contentHash = contentHash
|
||||||
|
self.formats = formats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Playlist: Identifiable, Hashable, Codable, Sendable {
|
||||||
|
public let id: EntityID
|
||||||
|
public var name: String
|
||||||
|
public var summary: String
|
||||||
|
public var artwork: EntityID?
|
||||||
|
public var trackCount: Int
|
||||||
|
public var duration: TimeInterval
|
||||||
|
public var updatedAt: Date
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: EntityID,
|
||||||
|
name: String,
|
||||||
|
summary: String = "",
|
||||||
|
artwork: EntityID? = nil,
|
||||||
|
trackCount: Int = 0,
|
||||||
|
duration: TimeInterval = 0,
|
||||||
|
updatedAt: Date = .distantPast
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.summary = summary
|
||||||
|
self.artwork = artwork
|
||||||
|
self.trackCount = trackCount
|
||||||
|
self.duration = duration
|
||||||
|
self.updatedAt = updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PlaylistEntry: Identifiable, Hashable, Codable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let track: EntityID
|
||||||
|
|
||||||
|
public init(id: String, track: EntityID) {
|
||||||
|
self.id = id
|
||||||
|
self.track = track
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RadioStation: Identifiable, Hashable, Codable, Sendable {
|
||||||
|
public let id: EntityID
|
||||||
|
public var name: String
|
||||||
|
public var tagline: String
|
||||||
|
public var streamURL: URL
|
||||||
|
public var artwork: EntityID?
|
||||||
|
|
||||||
|
public init(id: EntityID, name: String, tagline: String = "", streamURL: URL, artwork: EntityID? = nil) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.tagline = tagline
|
||||||
|
self.streamURL = streamURL
|
||||||
|
self.artwork = artwork
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Genre: Identifiable, Hashable, Sendable {
|
||||||
|
public var id: String {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
|
||||||
|
public let name: String
|
||||||
|
public let albumCount: Int
|
||||||
|
|
||||||
|
public init(name: String, albumCount: Int) {
|
||||||
|
self.name = name
|
||||||
|
self.albumCount = albumCount
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct SourceID: Hashable, Codable, Sendable {
|
||||||
|
public let raw: UUID
|
||||||
|
|
||||||
|
public init(_ raw: UUID = UUID()) {
|
||||||
|
self.raw = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct EntityID: Hashable, Codable, Sendable {
|
||||||
|
public let source: SourceID
|
||||||
|
public let remote: String
|
||||||
|
|
||||||
|
public init(source: SourceID, remote: String) {
|
||||||
|
self.source = source
|
||||||
|
self.remote = remote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum EntityKind: String, Codable, Sendable {
|
||||||
|
case artist, album, track, playlist, station
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct LyricLine: Hashable, Sendable, Identifiable {
|
||||||
|
public let time: TimeInterval
|
||||||
|
public let text: String
|
||||||
|
|
||||||
|
public var id: TimeInterval {
|
||||||
|
time
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(time: TimeInterval, text: String) {
|
||||||
|
self.time = time
|
||||||
|
self.text = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Lyrics: Hashable, Sendable {
|
||||||
|
public let lines: [LyricLine]
|
||||||
|
public let isSynced: Bool
|
||||||
|
|
||||||
|
public init(lines: [LyricLine], isSynced: Bool) {
|
||||||
|
self.lines = lines
|
||||||
|
self.isSynced = isSynced
|
||||||
|
}
|
||||||
|
|
||||||
|
public init?(lrc: String) {
|
||||||
|
var lines: [LyricLine] = []
|
||||||
|
var plain: [String] = []
|
||||||
|
for raw in lrc.split(whereSeparator: \.isNewline) {
|
||||||
|
var rest = Substring(raw)
|
||||||
|
var stamps: [TimeInterval] = []
|
||||||
|
while rest.first == "[", let close = rest.firstIndex(of: "]") {
|
||||||
|
let tag = rest[rest.index(after: rest.startIndex) ..< close]
|
||||||
|
rest = rest[rest.index(after: close)...]
|
||||||
|
if let time = Self.time(tag) {
|
||||||
|
stamps.append(time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let text = rest.trimmingCharacters(in: .whitespaces)
|
||||||
|
if stamps.isEmpty {
|
||||||
|
if !text.isEmpty {
|
||||||
|
plain.append(text)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines += stamps.map { LyricLine(time: $0, text: text) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !lines.isEmpty {
|
||||||
|
self.init(lines: lines.sorted { $0.time < $1.time }, isSynced: true)
|
||||||
|
} else if !plain.isEmpty {
|
||||||
|
self.init(lines: plain.map { LyricLine(time: 0, text: $0) }, isSynced: false)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func index(at time: TimeInterval) -> Int? {
|
||||||
|
guard isSynced else { return nil }
|
||||||
|
var found: Int?
|
||||||
|
for (index, line) in lines.enumerated() where line.time <= time {
|
||||||
|
found = index
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func time(_ tag: Substring) -> TimeInterval? {
|
||||||
|
let parts = tag.split(separator: ":")
|
||||||
|
guard parts.count == 2, let minutes = Double(parts[0]), let seconds = Double(parts[1]) else { return nil }
|
||||||
|
return minutes * 60 + seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol LyricsSource: LibrarySource {
|
||||||
|
func lyrics(for track: EntityID) async throws -> Lyrics?
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct ResourceRequest: Hashable, Sendable {
|
||||||
|
public var url: URL
|
||||||
|
public var headers: [String: String]
|
||||||
|
public var expiresAt: Date?
|
||||||
|
|
||||||
|
public init(url: URL, headers: [String: String] = [:], expiresAt: Date? = nil) {
|
||||||
|
self.url = url
|
||||||
|
self.headers = headers
|
||||||
|
self.expiresAt = expiresAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct StreamRequest: Hashable, Sendable {
|
||||||
|
public var resource: ResourceRequest
|
||||||
|
public var format: AudioFormat?
|
||||||
|
|
||||||
|
public init(resource: ResourceRequest, format: AudioFormat?) {
|
||||||
|
self.resource = resource
|
||||||
|
self.format = format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Cursor: Hashable, Codable, Sendable {
|
||||||
|
public let raw: String
|
||||||
|
|
||||||
|
public init(_ raw: String) {
|
||||||
|
self.raw = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Annotation: Hashable, Codable, Sendable {
|
||||||
|
public var favoritedAt: Date?
|
||||||
|
public var playCount: Int
|
||||||
|
public var lastPlayedAt: Date?
|
||||||
|
|
||||||
|
public init(favoritedAt: Date? = nil, playCount: Int = 0, lastPlayedAt: Date? = nil) {
|
||||||
|
self.favoritedAt = favoritedAt
|
||||||
|
self.playCount = playCount
|
||||||
|
self.lastPlayedAt = lastPlayedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Entity: Sendable {
|
||||||
|
case artist(Artist)
|
||||||
|
case album(Album)
|
||||||
|
case track(Track)
|
||||||
|
case playlist(Playlist, entries: [PlaylistEntry])
|
||||||
|
case station(RadioStation)
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Change: Sendable {
|
||||||
|
case upsert(Entity)
|
||||||
|
case remove(EntityKind, EntityID)
|
||||||
|
case annotate(EntityID, Annotation)
|
||||||
|
case checkpoint(Cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SourceError: Error {
|
||||||
|
case cannotCalculateChanges
|
||||||
|
case notFound
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol LibrarySource: Sendable {
|
||||||
|
var id: SourceID { get }
|
||||||
|
var name: String { get }
|
||||||
|
func changes(since cursor: Cursor?) -> AsyncThrowingStream<Change, Error>
|
||||||
|
func stream(for track: EntityID) async throws -> StreamRequest
|
||||||
|
func artwork(_ id: EntityID, pixelSize: Int) async throws -> ResourceRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol Starring: LibrarySource {
|
||||||
|
func setFavorite(_ isFavorite: Bool, for id: EntityID, kind: EntityKind) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol Scrobbling: LibrarySource {
|
||||||
|
func scrobble(_ track: EntityID, at date: Date, submission: Bool) async throws
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
#if canImport(AVFoundation)
|
||||||
|
import AVFoundation
|
||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public actor AVFoundationEngine: OutputEngine {
|
||||||
|
public typealias Resolve = @Sendable (Track) async throws -> StreamRequest
|
||||||
|
|
||||||
|
private struct Slot {
|
||||||
|
let item: QueueItem
|
||||||
|
let playerItem: AVPlayerItem
|
||||||
|
let statusObservation: NSKeyValueObservation
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated let events: AsyncStream<OutputEvent>
|
||||||
|
private let continuation: AsyncStream<OutputEvent>.Continuation
|
||||||
|
private let resolve: Resolve
|
||||||
|
private let player = AVQueuePlayer()
|
||||||
|
private let bufferAhead: TimeInterval = 60
|
||||||
|
private let anchorInterval: TimeInterval = 5
|
||||||
|
private var current: Slot?
|
||||||
|
private var prepared: Slot?
|
||||||
|
private var adoptable = false
|
||||||
|
private var wantsPlaying = false
|
||||||
|
private var sessionActive = false
|
||||||
|
private var loadGeneration = 0
|
||||||
|
private var prepareGeneration = 0
|
||||||
|
private var observations: [NSKeyValueObservation] = []
|
||||||
|
private var tokens: [any NSObjectProtocol] = []
|
||||||
|
private var timeObserver: Any?
|
||||||
|
|
||||||
|
public init(resolve: @escaping Resolve) {
|
||||||
|
self.resolve = resolve
|
||||||
|
(events, continuation) = AsyncStream<OutputEvent>.makeStream()
|
||||||
|
player.automaticallyWaitsToMinimizeStalling = true
|
||||||
|
player.audiovisualBackgroundPlaybackPolicy = .continuesIfPossible
|
||||||
|
}
|
||||||
|
|
||||||
|
isolated deinit {
|
||||||
|
tokens.forEach { NotificationCenter.default.removeObserver($0) }
|
||||||
|
if let timeObserver {
|
||||||
|
player.removeTimeObserver(timeObserver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var outputLatency: TimeInterval {
|
||||||
|
AudioSession.outputLatency
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load(_ item: QueueItem, at position: TimeInterval) async {
|
||||||
|
startObserving()
|
||||||
|
loadGeneration += 1
|
||||||
|
let generation = loadGeneration
|
||||||
|
if adoptable, current?.item.id == item.id {
|
||||||
|
adoptable = false
|
||||||
|
} else if let prepared, prepared.item.id == item.id {
|
||||||
|
player.advanceToNextItem()
|
||||||
|
current = prepared
|
||||||
|
self.prepared = nil
|
||||||
|
} else {
|
||||||
|
guard let slot = await makeSlot(item), generation == loadGeneration else { return }
|
||||||
|
player.removeAllItems()
|
||||||
|
prepared = nil
|
||||||
|
player.insert(slot.playerItem, after: nil)
|
||||||
|
current = slot
|
||||||
|
}
|
||||||
|
adoptable = false
|
||||||
|
if position > 0 {
|
||||||
|
await seek(to: position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func prepare(_ item: QueueItem?) async {
|
||||||
|
startObserving()
|
||||||
|
guard prepared?.item.id != item?.id else { return }
|
||||||
|
prepareGeneration += 1
|
||||||
|
let generation = prepareGeneration
|
||||||
|
if let prepared {
|
||||||
|
player.remove(prepared.playerItem)
|
||||||
|
self.prepared = nil
|
||||||
|
}
|
||||||
|
guard let item, current != nil else { return }
|
||||||
|
guard let slot = await makeSlot(item), generation == prepareGeneration, current != nil else { return }
|
||||||
|
player.insert(slot.playerItem, after: nil)
|
||||||
|
prepared = slot
|
||||||
|
}
|
||||||
|
|
||||||
|
public func play() async {
|
||||||
|
wantsPlaying = true
|
||||||
|
if !sessionActive {
|
||||||
|
sessionActive = await (try? AudioSession.activate()) != nil
|
||||||
|
}
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pause() {
|
||||||
|
wantsPlaying = false
|
||||||
|
player.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func seek(to position: TimeInterval) async {
|
||||||
|
let time = CMTime(seconds: position, preferredTimescale: 600)
|
||||||
|
_ = await player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
||||||
|
reportAnchor()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
wantsPlaying = false
|
||||||
|
player.pause()
|
||||||
|
player.removeAllItems()
|
||||||
|
current = nil
|
||||||
|
prepared = nil
|
||||||
|
adoptable = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSlot(_ item: QueueItem) async -> Slot? {
|
||||||
|
do {
|
||||||
|
let request = try await resolve(item.track)
|
||||||
|
let playerItem = makePlayerItem(request)
|
||||||
|
let observation = playerItem.observe(\.status, options: [.new]) { @Sendable [weak self] playerItem, _ in
|
||||||
|
guard playerItem.status == .failed else { return }
|
||||||
|
let id = ObjectIdentifier(playerItem)
|
||||||
|
Task { await self?.itemFailed(id) }
|
||||||
|
}
|
||||||
|
return Slot(item: item, playerItem: playerItem, statusObservation: observation)
|
||||||
|
} catch {
|
||||||
|
continuation.yield(.failed(item.id))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makePlayerItem(_ request: StreamRequest) -> AVPlayerItem {
|
||||||
|
var options: [String: any Sendable] = [AVURLAssetPreferPreciseDurationAndTimingKey: true]
|
||||||
|
if !request.resource.headers.isEmpty {
|
||||||
|
options["AVURLAssetHTTPHeaderFieldsKey"] = request.resource.headers
|
||||||
|
}
|
||||||
|
let asset = AVURLAsset(url: request.resource.url, options: options)
|
||||||
|
let playerItem = AVPlayerItem(asset: asset)
|
||||||
|
playerItem.preferredForwardBufferDuration = bufferAhead
|
||||||
|
return playerItem
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startObserving() {
|
||||||
|
guard timeObserver == nil else { return }
|
||||||
|
let interval = CMTime(seconds: anchorInterval, preferredTimescale: 600)
|
||||||
|
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] _ in
|
||||||
|
Task { await self?.reportAnchor() }
|
||||||
|
}
|
||||||
|
observations.append(player.observe(\.timeControlStatus, options: [.new]) { @Sendable [weak self] _, _ in
|
||||||
|
Task { await self?.reportAnchor() }
|
||||||
|
})
|
||||||
|
tokens.append(observeItems(.AVPlayerItemDidPlayToEndTime) { [weak self] id in await self?.itemEnded(id) })
|
||||||
|
tokens.append(observeItems(.AVPlayerItemFailedToPlayToEndTime) { [weak self] id in await self?.itemFailed(id) })
|
||||||
|
tokens.append(contentsOf: AudioSession.observe(
|
||||||
|
interrupted: { [weak self] began in Task { await self?.interruption(began: began) } },
|
||||||
|
routeLost: { [weak self] in Task { await self?.routeLost() } }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeItems(
|
||||||
|
_ name: Notification.Name,
|
||||||
|
handler: @escaping @Sendable (ObjectIdentifier) async -> Void
|
||||||
|
) -> any NSObjectProtocol {
|
||||||
|
NotificationCenter.default.addObserver(forName: name, object: nil, queue: nil) { notification in
|
||||||
|
guard let playerItem = notification.object as? AVPlayerItem else { return }
|
||||||
|
let id = ObjectIdentifier(playerItem)
|
||||||
|
Task { await handler(id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func itemEnded(_ id: ObjectIdentifier) {
|
||||||
|
guard let current, ObjectIdentifier(current.playerItem) == id else { return }
|
||||||
|
continuation.yield(.finished(current.item.id))
|
||||||
|
self.current = prepared
|
||||||
|
prepared = nil
|
||||||
|
adoptable = self.current != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func itemFailed(_ id: ObjectIdentifier) {
|
||||||
|
if let current, ObjectIdentifier(current.playerItem) == id {
|
||||||
|
continuation.yield(.failed(current.item.id))
|
||||||
|
} else if let prepared, ObjectIdentifier(prepared.playerItem) == id {
|
||||||
|
player.remove(prepared.playerItem)
|
||||||
|
self.prepared = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reportAnchor() {
|
||||||
|
guard let playing = player.currentItem,
|
||||||
|
let slot = [current, prepared].compactMap(\.self).first(where: { $0.playerItem === playing })
|
||||||
|
else { return }
|
||||||
|
let seconds = player.currentTime().seconds
|
||||||
|
guard seconds.isFinite else { return }
|
||||||
|
let rate = player.timeControlStatus == .playing ? Double(player.rate) : 0
|
||||||
|
continuation.yield(.anchor(slot.item.id, position: seconds, rate: rate))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func interruption(began: Bool) {
|
||||||
|
if began {
|
||||||
|
player.pause()
|
||||||
|
reportAnchor()
|
||||||
|
} else if wantsPlaying {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func routeLost() {
|
||||||
|
wantsPlaying = false
|
||||||
|
player.pause()
|
||||||
|
reportAnchor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#if canImport(AVFoundation)
|
||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AudioSession {
|
||||||
|
typealias Token = any NSObjectProtocol
|
||||||
|
|
||||||
|
#if os(iOS) || os(watchOS)
|
||||||
|
static var outputLatency: TimeInterval {
|
||||||
|
AVAudioSession.sharedInstance().outputLatency
|
||||||
|
}
|
||||||
|
|
||||||
|
static func activate() async throws {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
#if os(watchOS)
|
||||||
|
try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
|
||||||
|
_ = try await session.activate()
|
||||||
|
#else
|
||||||
|
try session.setCategory(.playback, mode: .default)
|
||||||
|
try session.setActive(true)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static func observe(
|
||||||
|
interrupted: @escaping @Sendable (Bool) -> Void,
|
||||||
|
routeLost: @escaping @Sendable () -> Void
|
||||||
|
) -> [Token] {
|
||||||
|
let center = NotificationCenter.default
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
let interruption = center.addObserver(forName: AVAudioSession.interruptionNotification, object: session, queue: nil) {
|
||||||
|
guard let raw = $0.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||||
|
let type = AVAudioSession.InterruptionType(rawValue: raw)
|
||||||
|
else { return }
|
||||||
|
switch type {
|
||||||
|
case .began:
|
||||||
|
interrupted(true)
|
||||||
|
case .ended:
|
||||||
|
let options = $0.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
||||||
|
if AVAudioSession.InterruptionOptions(rawValue: options).contains(.shouldResume) {
|
||||||
|
interrupted(false)
|
||||||
|
}
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let route = center.addObserver(forName: AVAudioSession.routeChangeNotification, object: session, queue: nil) {
|
||||||
|
let raw = $0.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt
|
||||||
|
if raw.flatMap(AVAudioSession.RouteChangeReason.init) == .oldDeviceUnavailable {
|
||||||
|
routeLost()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [interruption, route]
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
static var outputLatency: TimeInterval {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
static func activate() async throws {}
|
||||||
|
|
||||||
|
static func observe(
|
||||||
|
interrupted _: @escaping @Sendable (Bool) -> Void,
|
||||||
|
routeLost _: @escaping @Sendable () -> Void
|
||||||
|
) -> [Token] {
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct QueueItem: Identifiable, Hashable, Codable, Sendable {
|
||||||
|
public let id: UUID
|
||||||
|
public let track: Track
|
||||||
|
|
||||||
|
public init(id: UUID = UUID(), track: Track) {
|
||||||
|
self.id = id
|
||||||
|
self.track = track
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RepeatMode: String, Codable, Sendable, CaseIterable {
|
||||||
|
case off, all, one
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum PlaybackCommand: Codable, Sendable {
|
||||||
|
case play
|
||||||
|
case pause
|
||||||
|
case togglePlayPause
|
||||||
|
case seek(to: TimeInterval)
|
||||||
|
case next
|
||||||
|
case previous
|
||||||
|
case setQueue(tracks: [Track], startIndex: Int, shuffled: Bool)
|
||||||
|
case insertNext([Track])
|
||||||
|
case insertLast([Track])
|
||||||
|
case jump(to: QueueItem.ID)
|
||||||
|
case move(item: QueueItem.ID, after: QueueItem.ID?)
|
||||||
|
case remove(QueueItem.ID)
|
||||||
|
case clearUpcoming
|
||||||
|
case setShuffle(Bool)
|
||||||
|
case setRepeat(RepeatMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PlaybackAnchor: Hashable, Sendable {
|
||||||
|
public var position: TimeInterval
|
||||||
|
public var instant: ContinuousClock.Instant
|
||||||
|
public var rate: Double
|
||||||
|
|
||||||
|
public init(position: TimeInterval, instant: ContinuousClock.Instant, rate: Double) {
|
||||||
|
self.position = position
|
||||||
|
self.instant = instant
|
||||||
|
self.rate = rate
|
||||||
|
}
|
||||||
|
|
||||||
|
public func position(at now: ContinuousClock.Instant) -> TimeInterval {
|
||||||
|
let elapsed = now - instant
|
||||||
|
let seconds = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
|
||||||
|
return position + seconds * rate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum PlaybackStatus: String, Sendable {
|
||||||
|
case stopped, paused, playing
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PlaybackState: Hashable, Sendable {
|
||||||
|
public var history: [QueueItem] = []
|
||||||
|
public var current: QueueItem?
|
||||||
|
public var upcoming: [QueueItem] = []
|
||||||
|
public var status: PlaybackStatus = .stopped
|
||||||
|
public var anchor = PlaybackAnchor(position: 0, instant: .now, rate: 0)
|
||||||
|
public var isShuffled = false
|
||||||
|
public var repeatMode: RepeatMode = .off
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum OutputEvent: Sendable {
|
||||||
|
case finished(QueueItem.ID)
|
||||||
|
case failed(QueueItem.ID)
|
||||||
|
case anchor(QueueItem.ID, position: TimeInterval, rate: Double)
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol OutputEngine: Sendable {
|
||||||
|
var events: AsyncStream<OutputEvent> { get }
|
||||||
|
func load(_ item: QueueItem, at position: TimeInterval) async
|
||||||
|
func prepare(_ item: QueueItem?) async
|
||||||
|
func play() async
|
||||||
|
func pause() async
|
||||||
|
func seek(to position: TimeInterval) async
|
||||||
|
func stop() async
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension OutputEngine {
|
||||||
|
func prepare(_: QueueItem?) async {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public actor PlaybackSession {
|
||||||
|
public typealias Now = @Sendable () -> ContinuousClock.Instant
|
||||||
|
|
||||||
|
private let engine: any OutputEngine
|
||||||
|
private let now: Now
|
||||||
|
private var state = PlaybackState()
|
||||||
|
private var originalOrder: [QueueItem.ID] = []
|
||||||
|
private var observers: [UUID: AsyncStream<PlaybackState>.Continuation] = [:]
|
||||||
|
private var eventTask: Task<Void, Never>?
|
||||||
|
private var preparedKey: [QueueItem.ID?] = []
|
||||||
|
private var failureStreak = 0
|
||||||
|
private let restartThreshold: TimeInterval = 3
|
||||||
|
|
||||||
|
public init(engine: any OutputEngine, now: @escaping Now = { .now }) {
|
||||||
|
self.engine = engine
|
||||||
|
self.now = now
|
||||||
|
}
|
||||||
|
|
||||||
|
public func states() -> AsyncStream<PlaybackState> {
|
||||||
|
listenToEngine()
|
||||||
|
let key = UUID()
|
||||||
|
let (stream, continuation) = AsyncStream<PlaybackState>.makeStream(bufferingPolicy: .bufferingNewest(1))
|
||||||
|
observers[key] = continuation
|
||||||
|
continuation.onTermination = { [weak self] _ in
|
||||||
|
Task { await self?.removeObserver(key) }
|
||||||
|
}
|
||||||
|
continuation.yield(state)
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
|
||||||
|
public var snapshot: PlaybackState {
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
public func send(_ command: PlaybackCommand) async {
|
||||||
|
listenToEngine()
|
||||||
|
failureStreak = 0
|
||||||
|
switch command {
|
||||||
|
case .play: await resume()
|
||||||
|
case .pause: await pause()
|
||||||
|
case .togglePlayPause: state.status == .playing ? await pause() : await resume()
|
||||||
|
case let .seek(position): await seek(to: position)
|
||||||
|
case .next: await advance(auto: false)
|
||||||
|
case .previous: await goBack()
|
||||||
|
case let .setQueue(tracks, startIndex, shuffled): await setQueue(tracks, startIndex: startIndex, shuffled: shuffled)
|
||||||
|
case let .insertNext(tracks): insert(tracks, atEnd: false)
|
||||||
|
case let .insertLast(tracks): insert(tracks, atEnd: true)
|
||||||
|
case let .jump(id): await jump(to: id)
|
||||||
|
case let .move(item, after): move(item, after: after)
|
||||||
|
case let .remove(id): state.upcoming.removeAll { $0.id == id }
|
||||||
|
case .clearUpcoming: state.upcoming.removeAll()
|
||||||
|
case let .setShuffle(isOn): setShuffle(isOn)
|
||||||
|
case let .setRepeat(mode): state.repeatMode = mode
|
||||||
|
}
|
||||||
|
publish()
|
||||||
|
await syncPrepared()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func listenToEngine() {
|
||||||
|
guard eventTask == nil else { return }
|
||||||
|
let events = engine.events
|
||||||
|
eventTask = Task { [weak self] in
|
||||||
|
for await event in events {
|
||||||
|
await self?.handle(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(_ event: OutputEvent) async {
|
||||||
|
switch event {
|
||||||
|
case let .finished(id) where id == state.current?.id:
|
||||||
|
failureStreak = 0
|
||||||
|
await finishCurrent()
|
||||||
|
case let .failed(id) where id == state.current?.id:
|
||||||
|
await failCurrent()
|
||||||
|
case let .anchor(id, position, rate) where id == state.current?.id:
|
||||||
|
state.anchor = PlaybackAnchor(position: position, instant: now(), rate: state.status == .playing ? rate : 0)
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
publish()
|
||||||
|
await syncPrepared()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishCurrent() async {
|
||||||
|
if state.repeatMode == .one {
|
||||||
|
await start(at: 0, playing: true)
|
||||||
|
} else {
|
||||||
|
await advance(auto: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func failCurrent() async {
|
||||||
|
failureStreak += 1
|
||||||
|
guard failureStreak <= state.history.count + state.upcoming.count else {
|
||||||
|
anchor(0, playing: false)
|
||||||
|
await engine.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await advance(auto: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var nextToPrepare: QueueItem? {
|
||||||
|
if let next = state.upcoming.first {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
switch state.repeatMode {
|
||||||
|
case .off: return nil
|
||||||
|
case .one: return state.current
|
||||||
|
case .all: return state.history.first ?? state.current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncPrepared() async {
|
||||||
|
let key = [state.current?.id, nextToPrepare?.id]
|
||||||
|
guard key != preparedKey else { return }
|
||||||
|
preparedKey = key
|
||||||
|
await engine.prepare(state.current == nil ? nil : nextToPrepare)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeObserver(_ key: UUID) {
|
||||||
|
observers[key] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func publish() {
|
||||||
|
observers.values.forEach { $0.yield(state) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func anchor(_ position: TimeInterval, playing: Bool) {
|
||||||
|
state.anchor = PlaybackAnchor(position: position, instant: now(), rate: playing ? 1 : 0)
|
||||||
|
state.status = playing ? .playing : .paused
|
||||||
|
}
|
||||||
|
|
||||||
|
private var position: TimeInterval {
|
||||||
|
let raw = state.anchor.position(at: now())
|
||||||
|
return min(max(raw, 0), state.current?.track.duration ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resume() async {
|
||||||
|
guard state.current != nil, state.status != .playing || state.anchor.rate == 0 else { return }
|
||||||
|
anchor(position, playing: true)
|
||||||
|
await engine.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pause() async {
|
||||||
|
guard state.status == .playing else { return }
|
||||||
|
anchor(position, playing: false)
|
||||||
|
await engine.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func seek(to target: TimeInterval) async {
|
||||||
|
guard let current = state.current else { return }
|
||||||
|
let clamped = min(max(target, 0), current.track.duration)
|
||||||
|
anchor(clamped, playing: state.status == .playing)
|
||||||
|
await engine.seek(to: clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func start(at position: TimeInterval, playing: Bool) async {
|
||||||
|
guard let current = state.current else {
|
||||||
|
state.status = .stopped
|
||||||
|
state.anchor = PlaybackAnchor(position: 0, instant: now(), rate: 0)
|
||||||
|
await engine.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await engine.load(current, at: position)
|
||||||
|
anchor(position, playing: playing)
|
||||||
|
if playing {
|
||||||
|
await engine.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setQueue(_ tracks: [Track], startIndex: Int, shuffled: Bool) async {
|
||||||
|
let items = tracks.map { QueueItem(track: $0) }
|
||||||
|
originalOrder = items.map(\.id)
|
||||||
|
state.history = []
|
||||||
|
state.isShuffled = shuffled
|
||||||
|
guard !items.isEmpty else {
|
||||||
|
state.current = nil
|
||||||
|
state.upcoming = []
|
||||||
|
await start(at: 0, playing: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if shuffled {
|
||||||
|
var rest = items
|
||||||
|
let first = rest.remove(at: Int.random(in: rest.indices))
|
||||||
|
state.current = first
|
||||||
|
state.upcoming = rest.shuffled()
|
||||||
|
} else {
|
||||||
|
let index = min(max(startIndex, 0), items.count - 1)
|
||||||
|
state.history = Array(items[..<index])
|
||||||
|
state.current = items[index]
|
||||||
|
state.upcoming = Array(items[(index + 1)...])
|
||||||
|
}
|
||||||
|
await start(at: 0, playing: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func insert(_ tracks: [Track], atEnd: Bool) {
|
||||||
|
let items = tracks.map { QueueItem(track: $0) }
|
||||||
|
originalOrder.append(contentsOf: items.map(\.id))
|
||||||
|
if atEnd {
|
||||||
|
state.upcoming.append(contentsOf: items)
|
||||||
|
} else {
|
||||||
|
state.upcoming.insert(contentsOf: items, at: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func advance(auto: Bool) async {
|
||||||
|
guard let current = state.current else { return }
|
||||||
|
var keepsPlaying = auto || state.status == .playing
|
||||||
|
if state.upcoming.isEmpty {
|
||||||
|
let all = state.history + [current]
|
||||||
|
state.history = []
|
||||||
|
state.current = all.first
|
||||||
|
state.upcoming = Array(all.dropFirst())
|
||||||
|
keepsPlaying = keepsPlaying && state.repeatMode != .off
|
||||||
|
} else {
|
||||||
|
state.history.append(current)
|
||||||
|
state.current = state.upcoming.removeFirst()
|
||||||
|
}
|
||||||
|
await start(at: 0, playing: keepsPlaying)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func goBack() async {
|
||||||
|
guard let current = state.current else { return }
|
||||||
|
guard position <= restartThreshold, let previous = state.history.popLast() else {
|
||||||
|
await seek(to: 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.upcoming.insert(current, at: 0)
|
||||||
|
state.current = previous
|
||||||
|
await start(at: 0, playing: state.status == .playing)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func jump(to id: QueueItem.ID) async {
|
||||||
|
guard let current = state.current else { return }
|
||||||
|
if let index = state.upcoming.firstIndex(where: { $0.id == id }) {
|
||||||
|
state.history.append(current)
|
||||||
|
state.history.append(contentsOf: state.upcoming[..<index])
|
||||||
|
state.current = state.upcoming[index]
|
||||||
|
state.upcoming.removeSubrange(...index)
|
||||||
|
} else if let index = state.history.firstIndex(where: { $0.id == id }) {
|
||||||
|
state.upcoming.insert(contentsOf: Array(state.history[(index + 1)...]) + [current], at: 0)
|
||||||
|
state.current = state.history[index]
|
||||||
|
state.history.removeSubrange(index...)
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await start(at: 0, playing: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func move(_ id: QueueItem.ID, after target: QueueItem.ID?) {
|
||||||
|
guard let from = state.upcoming.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
let item = state.upcoming.remove(at: from)
|
||||||
|
let index = target.flatMap { anchorID in state.upcoming.firstIndex { $0.id == anchorID } }.map { $0 + 1 } ?? 0
|
||||||
|
state.upcoming.insert(item, at: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setShuffle(_ isOn: Bool) {
|
||||||
|
state.isShuffled = isOn
|
||||||
|
if isOn {
|
||||||
|
state.upcoming.shuffle()
|
||||||
|
} else {
|
||||||
|
let rank = Dictionary(uniqueKeysWithValues: originalOrder.enumerated().map { ($1, $0) })
|
||||||
|
state.upcoming.sort { (rank[$0.id] ?? .max) < (rank[$1.id] ?? .max) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public actor SilentEngine: OutputEngine {
|
||||||
|
public nonisolated let events: AsyncStream<OutputEvent>
|
||||||
|
private let continuation: AsyncStream<OutputEvent>.Continuation
|
||||||
|
private var item: QueueItem?
|
||||||
|
private var position: TimeInterval = 0
|
||||||
|
private var startedAt: ContinuousClock.Instant?
|
||||||
|
private var timer: Task<Void, Never>?
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
(events, continuation) = AsyncStream<OutputEvent>.makeStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load(_ item: QueueItem, at position: TimeInterval) {
|
||||||
|
timer?.cancel()
|
||||||
|
self.item = item
|
||||||
|
self.position = position
|
||||||
|
startedAt = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func play() {
|
||||||
|
guard let item, startedAt == nil else { return }
|
||||||
|
startedAt = .now
|
||||||
|
let remaining = max(item.track.duration - position, 0)
|
||||||
|
timer = Task { [continuation] in
|
||||||
|
try? await Task.sleep(for: .seconds(remaining))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
continuation.yield(.finished(item.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pause() {
|
||||||
|
timer?.cancel()
|
||||||
|
if let startedAt {
|
||||||
|
let elapsed = ContinuousClock.now - startedAt
|
||||||
|
position += Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
|
||||||
|
}
|
||||||
|
startedAt = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func seek(to position: TimeInterval) {
|
||||||
|
let wasPlaying = startedAt != nil
|
||||||
|
pause()
|
||||||
|
self.position = position
|
||||||
|
if wasPlaying {
|
||||||
|
play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
timer?.cancel()
|
||||||
|
item = nil
|
||||||
|
startedAt = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
struct FakeArtwork {
|
||||||
|
private let context: CGContext
|
||||||
|
private let space = CGColorSpaceCreateDeviceRGB()
|
||||||
|
private let size: CGFloat
|
||||||
|
private let hue: Double
|
||||||
|
private let spread: Double
|
||||||
|
private var random: SeededGenerator
|
||||||
|
|
||||||
|
static func png(seed: String, side: Int) -> Data? {
|
||||||
|
guard var artwork = FakeArtwork(seed: seed, side: side) else { return nil }
|
||||||
|
artwork.draw()
|
||||||
|
guard let image = artwork.context.makeImage() else { return nil }
|
||||||
|
let data = NSMutableData()
|
||||||
|
guard let destination = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil) else { return nil }
|
||||||
|
CGImageDestinationAddImage(destination, image, nil)
|
||||||
|
return CGImageDestinationFinalize(destination) ? data as Data : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private init?(seed: String, side: Int) {
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: nil,
|
||||||
|
width: side,
|
||||||
|
height: side,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: 0,
|
||||||
|
space: space,
|
||||||
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||||
|
) else { return nil }
|
||||||
|
self.context = context
|
||||||
|
size = CGFloat(side)
|
||||||
|
random = SeededGenerator(seed: seed.utf8.reduce(UInt64(14_695_981_039_346_656_037)) { ($0 ^ UInt64($1)) &* 1_099_511_628_211 })
|
||||||
|
hue = .random(in: 0 ..< 1, using: &random)
|
||||||
|
spread = .random(in: 0.08 ... 0.45, using: &random)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func draw() {
|
||||||
|
let isDark = Bool.random(using: &random)
|
||||||
|
let ground = [color(hue, 0.75, isDark ? 0.35 : 0.95), color(hue + spread, 0.8, isDark ? 0.12 : 0.7)]
|
||||||
|
linear(ground, from: CGPoint(x: 0, y: size), to: CGPoint(x: size, y: 0))
|
||||||
|
switch Int.random(in: 0 ..< 4, using: &random) {
|
||||||
|
case 0: glows()
|
||||||
|
case 1: rings()
|
||||||
|
case 2: sun(horizon: ground[1])
|
||||||
|
default: blocks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func glows() {
|
||||||
|
for _ in 0 ..< 3 {
|
||||||
|
let center = CGPoint(x: .random(in: 0 ... size, using: &random), y: .random(in: 0 ... size, using: &random))
|
||||||
|
let tint = color(hue + .random(in: -0.2 ... 0.5, using: &random), 0.7, 1)
|
||||||
|
let radius = size * .random(in: 0.4 ... 0.8, using: &random)
|
||||||
|
guard let clear = tint.copy(alpha: 0),
|
||||||
|
let gradient = CGGradient(colorsSpace: space, colors: [tint, clear] as CFArray, locations: nil)
|
||||||
|
else { continue }
|
||||||
|
context.drawRadialGradient(gradient, startCenter: center, startRadius: 0, endCenter: center, endRadius: radius, options: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func rings() {
|
||||||
|
let center = CGPoint(x: size * .random(in: 0.3 ... 0.7, using: &random), y: size * .random(in: 0.3 ... 0.7, using: &random))
|
||||||
|
for ring in (1 ... 7).reversed() {
|
||||||
|
context.setFillColor(color(hue + spread * Double(ring) / 5, 0.65, ring.isMultiple(of: 2) ? 0.95 : 0.25))
|
||||||
|
let radius = size * 0.09 * CGFloat(ring)
|
||||||
|
context.fillEllipse(in: CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sun(horizon: CGColor) {
|
||||||
|
let radius = size * 0.32
|
||||||
|
let disc = CGRect(x: size / 2 - radius, y: size * 0.34, width: radius * 2, height: radius * 2)
|
||||||
|
context.saveGState()
|
||||||
|
context.addEllipse(in: disc)
|
||||||
|
context.clip()
|
||||||
|
linear(
|
||||||
|
[color(hue + 0.5, 0.5, 1), color(hue + 0.62, 0.85, 1)],
|
||||||
|
from: CGPoint(x: 0, y: disc.maxY),
|
||||||
|
to: CGPoint(x: 0, y: disc.minY)
|
||||||
|
)
|
||||||
|
context.restoreGState()
|
||||||
|
context.setFillColor(horizon)
|
||||||
|
for stripe in 0 ..< 5 {
|
||||||
|
let height = size * 0.012 * CGFloat(5 - stripe)
|
||||||
|
context.fill(CGRect(x: 0, y: disc.minY + CGFloat(stripe) * size * 0.06, width: size, height: height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func blocks() {
|
||||||
|
let cells = Int.random(in: 2 ... 3, using: &random)
|
||||||
|
let cell = size / CGFloat(cells)
|
||||||
|
for row in 0 ..< cells {
|
||||||
|
for column in 0 ..< cells where Bool.random(using: &random) {
|
||||||
|
let rect = CGRect(x: CGFloat(column) * cell, y: CGFloat(row) * cell, width: cell, height: cell)
|
||||||
|
context.setFillColor(color(hue + .random(in: 0 ... 0.6, using: &random), 0.7, .random(in: 0.3 ... 1, using: &random)))
|
||||||
|
if Bool.random(using: &random) {
|
||||||
|
context.fill(rect)
|
||||||
|
} else {
|
||||||
|
context.fillEllipse(in: rect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func color(_ hue: Double, _ saturation: Double, _ brightness: Double) -> CGColor {
|
||||||
|
let sector = (hue.truncatingRemainder(dividingBy: 1) + 1).truncatingRemainder(dividingBy: 1) * 6
|
||||||
|
let chroma = brightness * saturation
|
||||||
|
let second = chroma * (1 - abs(sector.truncatingRemainder(dividingBy: 2) - 1))
|
||||||
|
let table = [
|
||||||
|
[chroma, second, 0],
|
||||||
|
[second, chroma, 0],
|
||||||
|
[0, chroma, second],
|
||||||
|
[0, second, chroma],
|
||||||
|
[second, 0, chroma],
|
||||||
|
[chroma, 0, second],
|
||||||
|
]
|
||||||
|
let rgb = table[min(Int(sector), 5)].map { $0 + brightness - chroma }
|
||||||
|
return CGColor(red: rgb[0], green: rgb[1], blue: rgb[2], alpha: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func linear(_ colors: [CGColor], from: CGPoint, to: CGPoint) {
|
||||||
|
guard let gradient = CGGradient(colorsSpace: space, colors: colors as CFArray, locations: nil) else { return }
|
||||||
|
context.drawLinearGradient(gradient, start: from, end: to, options: [.drawsBeforeStartLocation, .drawsAfterEndLocation])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct SeededGenerator: RandomNumberGenerator {
|
||||||
|
private var state: UInt64
|
||||||
|
|
||||||
|
init(seed: UInt64) {
|
||||||
|
state = seed &+ 0x9E37_79B9_7F4A_7C15
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func next() -> UInt64 {
|
||||||
|
state &+= 0x9E37_79B9_7F4A_7C15
|
||||||
|
var mixed = state
|
||||||
|
mixed = (mixed ^ (mixed >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
||||||
|
mixed = (mixed ^ (mixed >> 27)) &* 0x94D0_49BB_1331_11EB
|
||||||
|
return mixed ^ (mixed >> 31)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FakeCatalog {
|
||||||
|
struct AlbumSeed {
|
||||||
|
let title: String
|
||||||
|
let year: Int
|
||||||
|
let tracks: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ArtistSeed {
|
||||||
|
let name: String
|
||||||
|
let genre: String
|
||||||
|
let albums: [AlbumSeed]
|
||||||
|
}
|
||||||
|
|
||||||
|
static let artists: [ArtistSeed] = [
|
||||||
|
ArtistSeed(name: "Velvet Harbor", genre: "Indie Pop", albums: [
|
||||||
|
AlbumSeed(title: "Salt in the Signal", year: 2024, tracks: 11),
|
||||||
|
AlbumSeed(title: "Low Tide Radio", year: 2021, tracks: 10),
|
||||||
|
AlbumSeed(title: "Postcards From Nowhere", year: 2018, tracks: 12),
|
||||||
|
]),
|
||||||
|
ArtistSeed(name: "Нина Орлова", genre: "Alternative", albums: [
|
||||||
|
AlbumSeed(title: "Тихий час", year: 2025, tracks: 9),
|
||||||
|
AlbumSeed(title: "Север внутри", year: 2022, tracks: 11),
|
||||||
|
]),
|
||||||
|
ArtistSeed(name: "Kaito Mori", genre: "Electronic", albums: [
|
||||||
|
AlbumSeed(title: "Neon Monsoon", year: 2023, tracks: 8),
|
||||||
|
AlbumSeed(title: "東京 After Hours", year: 2020, tracks: 10),
|
||||||
|
AlbumSeed(title: "Glass Circuits", year: 2017, tracks: 9),
|
||||||
|
AlbumSeed(title: "Soft Machines (Remixes)", year: 2024, tracks: 6),
|
||||||
|
]),
|
||||||
|
ArtistSeed(name: "The Paper Lanterns", genre: "Rock", albums: [
|
||||||
|
AlbumSeed(title: "Everything Burns Brighter at Night and Other Small Disasters", year: 2019, tracks: 13),
|
||||||
|
AlbumSeed(title: "Static Bloom", year: 2016, tracks: 11),
|
||||||
|
AlbumSeed(title: "Live at the Old Observatory", year: 2022, tracks: 16),
|
||||||
|
]),
|
||||||
|
ArtistSeed(name: "Marisol Vega", genre: "Latin", albums: [
|
||||||
|
AlbumSeed(title: "Corazón de Verano", year: 2024, tracks: 12),
|
||||||
|
AlbumSeed(title: "Luna Roja", year: 2021, tracks: 10),
|
||||||
|
]),
|
||||||
|
ArtistSeed(name: "Oskar Lindqvist Trio", genre: "Jazz", albums: [
|
||||||
|
AlbumSeed(title: "Blue Hours", year: 2015, tracks: 7),
|
||||||
|
AlbumSeed(title: "Winter Standards", year: 2018, tracks: 9),
|
||||||
|
AlbumSeed(title: "Stockholm Sessions", year: 2023, tracks: 8),
|
||||||
|
]),
|
||||||
|
ArtistSeed(name: "DJ Quartz", genre: "Hip-Hop", albums: [
|
||||||
|
AlbumSeed(title: "Concrete Gardens", year: 2022, tracks: 14),
|
||||||
|
AlbumSeed(title: "No Signal", year: 2025, tracks: 12),
|
||||||
|
]),
|
||||||
|
ArtistSeed(name: "Aurora Fields", genre: "Ambient", albums: [
|
||||||
|
AlbumSeed(title: "Weightless", year: 2020, tracks: 6),
|
||||||
|
AlbumSeed(title: "Slow Light", year: 2023, tracks: 7),
|
||||||
|
AlbumSeed(title: "Music for Empty Airports", year: 2017, tracks: 5),
|
||||||
|
]),
|
||||||
|
ArtistSeed(name: "Holloway & Finch", genre: "Folk", albums: [
|
||||||
|
AlbumSeed(title: "The Orchard Tapes", year: 2019, tracks: 10),
|
||||||
|
AlbumSeed(title: "Rivers We Crossed", year: 2024, tracks: 11),
|
||||||
|
]),
|
||||||
|
ArtistSeed(name: "Сёстры Грома", genre: "Rock", albums: [
|
||||||
|
AlbumSeed(title: "Электричество", year: 2023, tracks: 10),
|
||||||
|
AlbumSeed(title: "Город без сна", year: 2020, tracks: 12),
|
||||||
|
]),
|
||||||
|
ArtistSeed(name: "Camille Durand", genre: "Classical", albums: [
|
||||||
|
AlbumSeed(title: "Nocturnes & Preludes", year: 2021, tracks: 14),
|
||||||
|
AlbumSeed(title: "Satie: Works for Piano", year: 2016, tracks: 12),
|
||||||
|
]),
|
||||||
|
ArtistSeed(name: "Midnight Arcade", genre: "Electronic", albums: [
|
||||||
|
AlbumSeed(title: "Insert Coin", year: 2018, tracks: 10),
|
||||||
|
AlbumSeed(title: "High Score", year: 2021, tracks: 11),
|
||||||
|
AlbumSeed(title: "Continue?", year: 2025, tracks: 9),
|
||||||
|
]),
|
||||||
|
ArtistSeed(name: "Juno Okafor", genre: "R&B/Soul", albums: [
|
||||||
|
AlbumSeed(title: "Honey & Smoke", year: 2024, tracks: 12),
|
||||||
|
AlbumSeed(title: "Afterglow", year: 2022, tracks: 10),
|
||||||
|
]),
|
||||||
|
ArtistSeed(name: "Iron Meridian", genre: "Metal", albums: [
|
||||||
|
AlbumSeed(title: "Cathedral of Ash", year: 2019, tracks: 9),
|
||||||
|
AlbumSeed(title: "Tidal Lock", year: 2023, tracks: 8),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
static let openers = [
|
||||||
|
"Midnight", "Paper", "Golden", "Silent", "Electric", "Hollow", "Crimson", "Distant", "Velvet", "Broken",
|
||||||
|
"Summer", "Neon", "Quiet", "Wild", "Frozen", "Burning", "Little", "Endless", "Lonely", "Silver",
|
||||||
|
]
|
||||||
|
static let closers = [
|
||||||
|
"Hearts", "Rivers", "Machines", "Letters", "Highways", "Gardens", "Satellites", "Windows", "Echoes", "Tides",
|
||||||
|
"Dreams", "Wires", "Shadows", "Mornings", "Skylines", "Ghosts", "Oceans", "Rooms", "Signals", "Fires",
|
||||||
|
]
|
||||||
|
static let singles = [
|
||||||
|
"Interlude", "Overture", "Home", "Gravity", "Again", "Stay", "Run", "Waves", "Closer", "Tomorrow",
|
||||||
|
"Если бы", "Навсегда", "Après la pluie", "Sin Ti", "さよなら",
|
||||||
|
]
|
||||||
|
static let suffixes = ["", "", "", "", "", "", " (Reprise)", " (feat. Juno Okafor)", " (Acoustic)", ", Pt. 2"]
|
||||||
|
static let formats: [AudioFormat] = [
|
||||||
|
AudioFormat(codec: .flac, bitrateKbps: 1011, sampleRate: 44100, bitDepth: 16),
|
||||||
|
AudioFormat(codec: .flac, bitrateKbps: 2890, sampleRate: 96000, bitDepth: 24),
|
||||||
|
AudioFormat(codec: .flac, bitrateKbps: 1411, sampleRate: 48000, bitDepth: 24),
|
||||||
|
AudioFormat(codec: .mp3, bitrateKbps: 320, sampleRate: 44100),
|
||||||
|
AudioFormat(codec: .aac, bitrateKbps: 256, sampleRate: 44100),
|
||||||
|
]
|
||||||
|
|
||||||
|
let source: SourceID
|
||||||
|
private(set) var changes: [Change] = []
|
||||||
|
|
||||||
|
init(source: SourceID, now: Date = Date()) {
|
||||||
|
self.source = source
|
||||||
|
var random = SeededGenerator(seed: 42)
|
||||||
|
var allTracks: [Track] = []
|
||||||
|
var albumIndex = 0
|
||||||
|
for (artistIndex, seed) in Self.artists.enumerated() {
|
||||||
|
let artistID = id("artist-\(artistIndex)")
|
||||||
|
let artist = Artist(
|
||||||
|
id: artistID,
|
||||||
|
name: seed.name,
|
||||||
|
sortName: seed.name.hasPrefix("The ") ? String(seed.name.dropFirst(4)) : seed.name,
|
||||||
|
artwork: id("art-artist-\(artistIndex)"),
|
||||||
|
albumCount: seed.albums.count
|
||||||
|
)
|
||||||
|
changes.append(.upsert(.artist(artist)))
|
||||||
|
for albumSeed in seed.albums {
|
||||||
|
let tracks = makeTracks(albumSeed, albumIndex: albumIndex, artist: artist, genre: seed.genre, random: &random)
|
||||||
|
let album = Album(
|
||||||
|
id: id("album-\(albumIndex)"),
|
||||||
|
title: albumSeed.title,
|
||||||
|
artistID: artistID,
|
||||||
|
artistName: seed.name,
|
||||||
|
year: albumSeed.year,
|
||||||
|
genre: seed.genre,
|
||||||
|
artwork: id("art-album-\(albumIndex)"),
|
||||||
|
trackCount: tracks.count,
|
||||||
|
duration: tracks.reduce(0) { $0 + $1.duration },
|
||||||
|
addedAt: now.addingTimeInterval(-Double(Int.random(in: 1 ... 400, using: &random)) * 86400),
|
||||||
|
bestFormat: tracks.first?.formats.first
|
||||||
|
)
|
||||||
|
changes.append(.upsert(.album(album)))
|
||||||
|
changes.append(contentsOf: tracks.map { .upsert(.track($0)) })
|
||||||
|
allTracks.append(contentsOf: tracks)
|
||||||
|
albumIndex += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
makePlaylists(from: allTracks, now: now, random: &random)
|
||||||
|
makeAnnotations(for: allTracks, now: now, random: &random)
|
||||||
|
makeStations()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func id(_ remote: String) -> EntityID {
|
||||||
|
EntityID(source: source, remote: remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeTracks(
|
||||||
|
_ seed: AlbumSeed,
|
||||||
|
albumIndex: Int,
|
||||||
|
artist: Artist,
|
||||||
|
genre: String,
|
||||||
|
random: inout SeededGenerator
|
||||||
|
) -> [Track] {
|
||||||
|
let format = Self.formats[Int.random(in: 0 ..< Self.formats.count, using: &random)]
|
||||||
|
return (1 ... seed.tracks).map { number in
|
||||||
|
let base = Int.random(in: 0 ..< 5, using: &random) == 0
|
||||||
|
? Self.singles.randomElement(using: &random) ?? ""
|
||||||
|
: "\(Self.openers.randomElement(using: &random) ?? "") \(Self.closers.randomElement(using: &random) ?? "")"
|
||||||
|
return Track(
|
||||||
|
id: id("track-\(albumIndex)-\(number)"),
|
||||||
|
title: base + (Self.suffixes.randomElement(using: &random) ?? ""),
|
||||||
|
albumID: id("album-\(albumIndex)"),
|
||||||
|
albumTitle: seed.title,
|
||||||
|
artistID: artist.id,
|
||||||
|
artistName: artist.name,
|
||||||
|
trackNumber: number,
|
||||||
|
duration: genre == "Ambient" || genre == "Jazz"
|
||||||
|
? Double(Int.random(in: 280 ... 620, using: &random))
|
||||||
|
: Double(Int.random(in: 118 ... 325, using: &random)),
|
||||||
|
year: seed.year,
|
||||||
|
genre: genre,
|
||||||
|
artwork: id("art-album-\(albumIndex)"),
|
||||||
|
isExplicit: genre == "Hip-Hop" && Int.random(in: 0 ..< 2, using: &random) == 0,
|
||||||
|
formats: [format]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func makePlaylists(from tracks: [Track], now: Date, random: inout SeededGenerator) {
|
||||||
|
let seeds: [(name: String, summary: String)] = [
|
||||||
|
("Late Night Drive", "Windows down, city lights."),
|
||||||
|
("Focus", "Instrumental and quiet."),
|
||||||
|
("Утро", ""),
|
||||||
|
("Gym", "Loud."),
|
||||||
|
("Sunday Kitchen", "Cooking with the radio on."),
|
||||||
|
]
|
||||||
|
for (index, seed) in seeds.enumerated() {
|
||||||
|
let picked = Array(tracks.shuffled(using: &random).prefix(12 + index * 5))
|
||||||
|
let playlist = Playlist(
|
||||||
|
id: id("playlist-\(index)"),
|
||||||
|
name: seed.name,
|
||||||
|
summary: seed.summary,
|
||||||
|
artwork: id("art-playlist-\(index)"),
|
||||||
|
trackCount: picked.count,
|
||||||
|
duration: picked.reduce(0) { $0 + $1.duration },
|
||||||
|
updatedAt: now.addingTimeInterval(-Double(index) * 86400 * 9)
|
||||||
|
)
|
||||||
|
let entries = picked.enumerated().map { PlaylistEntry(id: "entry-\(index)-\($0.offset)", track: $0.element.id) }
|
||||||
|
changes.append(.upsert(.playlist(playlist, entries: entries)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func makeAnnotations(for tracks: [Track], now: Date, random: inout SeededGenerator) {
|
||||||
|
for track in tracks.shuffled(using: &random).prefix(140) {
|
||||||
|
let plays = Int.random(in: 1 ... 60, using: &random)
|
||||||
|
changes.append(.annotate(track.id, Annotation(
|
||||||
|
favoritedAt: plays > 42 ? now.addingTimeInterval(-Double(plays) * 3600) : nil,
|
||||||
|
playCount: plays,
|
||||||
|
lastPlayedAt: now.addingTimeInterval(-Double(Int.random(in: 1 ... 900, using: &random)) * 3600)
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func makeStations() {
|
||||||
|
let seeds = [
|
||||||
|
("Radio Paradise", "Eclectic, listener supported", "https://stream.radioparadise.com/flacm"),
|
||||||
|
("SomaFM Groove Salad", "Ambient and downtempo beats", "https://ice1.somafm.com/groovesalad-256-mp3"),
|
||||||
|
("KEXP 90.3", "Where the music matters", "https://kexp-mp3-128.streamguys1.com/kexp128.mp3"),
|
||||||
|
("FIP", "Radio France, sans frontières", "https://icecast.radiofrance.fr/fip-hifi.aac"),
|
||||||
|
]
|
||||||
|
for (index, seed) in seeds.enumerated() {
|
||||||
|
guard let url = URL(string: seed.2) else { continue }
|
||||||
|
changes.append(.upsert(.station(RadioStation(
|
||||||
|
id: id("station-\(index)"),
|
||||||
|
name: seed.0,
|
||||||
|
tagline: seed.1,
|
||||||
|
streamURL: url,
|
||||||
|
artwork: id("art-station-\(index)")
|
||||||
|
))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
import Synchronization
|
||||||
|
|
||||||
|
public final class FakeSource: LibrarySource, Starring, LyricsSource {
|
||||||
|
public let id: SourceID
|
||||||
|
public let name: String
|
||||||
|
private let catalog: FakeCatalog
|
||||||
|
private let artworkDirectory: URL
|
||||||
|
private struct Extras {
|
||||||
|
var changes: [Change] = []
|
||||||
|
var artwork: [String: URL] = [:]
|
||||||
|
var lyrics: [String: URL] = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
private let extras = Mutex(Extras())
|
||||||
|
|
||||||
|
public init(id: SourceID = SourceID(), name: String = "Demo Library") {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
catalog = FakeCatalog(source: id)
|
||||||
|
artworkDirectory = FileManager.default.temporaryDirectory.appending(path: "FakeSourceArtwork", directoryHint: .isDirectory)
|
||||||
|
try? FileManager.default.createDirectory(at: artworkDirectory, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var sampleTracks: [Track] {
|
||||||
|
catalog.changes.compactMap {
|
||||||
|
if case let .upsert(.track(track)) = $0 {
|
||||||
|
return track
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func add(_ changes: [Change], artworkFiles: [String: URL], lyricsFiles: [String: URL] = [:]) {
|
||||||
|
extras.withLock {
|
||||||
|
$0.changes += changes
|
||||||
|
$0.artwork.merge(artworkFiles) { $1 }
|
||||||
|
$0.lyrics.merge(lyricsFiles) { $1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func lyrics(for track: EntityID) async throws -> Lyrics? {
|
||||||
|
guard let file = extras.withLock({ $0.lyrics[track.remote] }) else { return nil }
|
||||||
|
return try Lyrics(lrc: String(contentsOf: file, encoding: .utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func changes(since cursor: Cursor?) -> AsyncThrowingStream<Change, Error> {
|
||||||
|
let changes = cursor == nil ? catalog.changes + extras.withLock(\.changes) : []
|
||||||
|
return AsyncThrowingStream { continuation in
|
||||||
|
changes.forEach { continuation.yield($0) }
|
||||||
|
continuation.yield(.checkpoint(Cursor("1")))
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stream(for _: EntityID) async throws -> StreamRequest {
|
||||||
|
throw SourceError.notFound
|
||||||
|
}
|
||||||
|
|
||||||
|
public func artwork(_ id: EntityID, pixelSize: Int) async throws -> ResourceRequest {
|
||||||
|
if let file = extras.withLock({ $0.artwork[id.remote] }) {
|
||||||
|
return ResourceRequest(url: file)
|
||||||
|
}
|
||||||
|
let side = pixelSize <= 320 ? 320 : 1024
|
||||||
|
let file = artworkDirectory.appending(path: "\(id.remote)-\(side).png")
|
||||||
|
if !FileManager.default.fileExists(atPath: file.path) {
|
||||||
|
guard let data = FakeArtwork.png(seed: id.remote, side: side) else { throw SourceError.notFound }
|
||||||
|
try data.write(to: file, options: .atomic)
|
||||||
|
}
|
||||||
|
return ResourceRequest(url: file)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setFavorite(_: Bool, for _: EntityID, kind _: EntityKind) async throws {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum SubsonicError: Error, Equatable, Sendable {
|
||||||
|
case server(code: Int, message: String)
|
||||||
|
case http(Int)
|
||||||
|
case malformed
|
||||||
|
}
|
||||||
|
|
||||||
|
typealias SubsonicTransport = @Sendable (URLRequest) async throws -> (Data, URLResponse)
|
||||||
|
|
||||||
|
struct SubsonicClient: Sendable {
|
||||||
|
let baseURL: URL
|
||||||
|
let username: String
|
||||||
|
let password: String
|
||||||
|
let transport: SubsonicTransport
|
||||||
|
|
||||||
|
init(baseURL: URL, username: String, password: String, transport: @escaping SubsonicTransport) {
|
||||||
|
self.baseURL = baseURL
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.transport = transport
|
||||||
|
}
|
||||||
|
|
||||||
|
init(baseURL: URL, username: String, password: String, session: URLSession) {
|
||||||
|
self.init(baseURL: baseURL, username: username, password: password) { request in
|
||||||
|
try await session.data(for: request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func url(_ method: String, _ params: [String: String] = [:]) -> URL {
|
||||||
|
let endpoint = baseURL.appending(path: "rest/\(method).view")
|
||||||
|
var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) ?? URLComponents()
|
||||||
|
components.queryItems = (params.sorted { $0.key < $1.key } + authParams()).map { URLQueryItem(name: $0.key, value: $0.value) }
|
||||||
|
return components.url ?? endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
func call<Payload: Decodable>(_ method: String, _ params: [String: String] = [:], as _: Payload.Type) async throws -> Payload {
|
||||||
|
let request = URLRequest(url: url(method, params))
|
||||||
|
let (data, response) = try await transport(request)
|
||||||
|
if let http = response as? HTTPURLResponse, http.statusCode >= 400 {
|
||||||
|
throw SubsonicError.http(http.statusCode)
|
||||||
|
}
|
||||||
|
return try Self.decode(data, as: Payload.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func call(_ method: String, _ params: [String: String] = [:]) async throws {
|
||||||
|
_ = try await call(method, params, as: EmptyPayload.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode<Payload: Decodable>(_ data: Data, as _: Payload.Type) throws -> Payload {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
let status: Wrapper<EnvelopeStatus>
|
||||||
|
do {
|
||||||
|
status = try decoder.decode(Wrapper<EnvelopeStatus>.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw SubsonicError.malformed
|
||||||
|
}
|
||||||
|
if status.response.status != "ok" {
|
||||||
|
let error = status.response.error
|
||||||
|
throw SubsonicError.server(code: error?.code ?? 0, message: error?.message ?? "")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try decoder.decode(Wrapper<Payload>.self, from: data).response
|
||||||
|
} catch {
|
||||||
|
throw SubsonicError.malformed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func authParams() -> [(key: String, value: String)] {
|
||||||
|
let salt = Self.salt()
|
||||||
|
let token = Insecure.MD5.hash(data: Data((password + salt).utf8)).map { String(format: "%02x", $0) }.joined()
|
||||||
|
return [("u", username), ("t", token), ("s", salt), ("v", "1.16.1"), ("c", "Amphlify"), ("f", "json")]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func salt() -> String {
|
||||||
|
let alphabet = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
||||||
|
return String((0 ..< 12).compactMap { _ in alphabet.randomElement() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Wrapper<Payload: Decodable>: Decodable {
|
||||||
|
let response: Payload
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case response = "subsonic-response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EnvelopeStatus: Decodable {
|
||||||
|
struct Failure: Decodable {
|
||||||
|
let code: Int
|
||||||
|
let message: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
let status: String
|
||||||
|
let error: Failure?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EmptyPayload: Decodable {}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct SubsonicMemory: Sendable {
|
||||||
|
var seen: [EntityKind: Set<String>] = [:]
|
||||||
|
var formats: [String: AudioFormat] = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SubsonicCrawl {
|
||||||
|
static let pageSize = 500
|
||||||
|
|
||||||
|
let client: SubsonicClient
|
||||||
|
let mapper: SubsonicMapper
|
||||||
|
let emit: (Change) -> Void
|
||||||
|
private var memory = SubsonicMemory()
|
||||||
|
private var bestByAlbum: [String: AudioFormat] = [:]
|
||||||
|
private var annotations: [Change] = []
|
||||||
|
|
||||||
|
init(client: SubsonicClient, mapper: SubsonicMapper, emit: @escaping (Change) -> Void) {
|
||||||
|
self.client = client
|
||||||
|
self.mapper = mapper
|
||||||
|
self.emit = emit
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func run(previous: [EntityKind: Set<String>]) async throws -> SubsonicMemory {
|
||||||
|
try await crawlArtists()
|
||||||
|
try await crawlTracks()
|
||||||
|
try await crawlAlbums()
|
||||||
|
try await crawlPlaylists()
|
||||||
|
try await crawlStations()
|
||||||
|
try await crawlStarred()
|
||||||
|
annotations.forEach(emit)
|
||||||
|
emitRemovals(previous: previous)
|
||||||
|
return memory
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func upsert(_ entity: Entity, kind: EntityKind, remote: String) {
|
||||||
|
memory.seen[kind, default: []].insert(remote)
|
||||||
|
emit(.upsert(entity))
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func crawlArtists() async throws {
|
||||||
|
let payload = try await client.call("getArtists", as: ArtistsPayload.self)
|
||||||
|
for index in payload.artists.index ?? [] {
|
||||||
|
for artist in index.artist ?? [] {
|
||||||
|
upsert(.artist(mapper.artist(artist)), kind: .artist, remote: artist.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func crawlTracks() async throws {
|
||||||
|
var offset = 0
|
||||||
|
while true {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let params = [
|
||||||
|
"query": "\"\"",
|
||||||
|
"songCount": String(Self.pageSize),
|
||||||
|
"songOffset": String(offset),
|
||||||
|
"artistCount": "0",
|
||||||
|
"albumCount": "0",
|
||||||
|
]
|
||||||
|
let songs = try await client.call("search3", params, as: SearchPayload.self).searchResult3.song ?? []
|
||||||
|
songs.forEach { consume($0) }
|
||||||
|
offset += songs.count
|
||||||
|
if songs.count < Self.pageSize {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func consume(_ song: SongJSON) {
|
||||||
|
let track = mapper.track(song)
|
||||||
|
upsert(.track(track), kind: .track, remote: song.id)
|
||||||
|
if let format = track.formats.first {
|
||||||
|
memory.formats[song.id] = format
|
||||||
|
if let albumId = song.albumId, format.isBetter(than: bestByAlbum[albumId]) {
|
||||||
|
bestByAlbum[albumId] = format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let annotation = mapper.annotation(song) {
|
||||||
|
annotations.append(.annotate(track.id, annotation))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func crawlAlbums() async throws {
|
||||||
|
var offset = 0
|
||||||
|
while true {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let params = ["type": "alphabeticalByName", "size": String(Self.pageSize), "offset": String(offset)]
|
||||||
|
let albums = try await client.call("getAlbumList2", params, as: AlbumListPayload.self).albumList2.album ?? []
|
||||||
|
for album in albums {
|
||||||
|
upsert(.album(mapper.album(album, bestFormat: bestByAlbum[album.id])), kind: .album, remote: album.id)
|
||||||
|
}
|
||||||
|
offset += albums.count
|
||||||
|
if albums.count < Self.pageSize {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func crawlPlaylists() async throws {
|
||||||
|
let playlists = try await client.call("getPlaylists", as: PlaylistsPayload.self).playlists.playlist ?? []
|
||||||
|
for summary in playlists {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let detail = try await client.call("getPlaylist", ["id": summary.id], as: PlaylistPayload.self).playlist
|
||||||
|
let (playlist, entries) = mapper.playlist(detail)
|
||||||
|
upsert(.playlist(playlist, entries: entries), kind: .playlist, remote: detail.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func crawlStations() async throws {
|
||||||
|
let stations = try await client.call("getInternetRadioStations", as: StationsPayload.self).internetRadioStations
|
||||||
|
for json in stations.internetRadioStation ?? [] {
|
||||||
|
if let station = mapper.station(json) {
|
||||||
|
upsert(.station(station), kind: .station, remote: json.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func crawlStarred() async throws {
|
||||||
|
let starred = try await client.call("getStarred2", as: StarredPayload.self).starred2
|
||||||
|
for artist in starred.artist ?? [] {
|
||||||
|
annotations.append(.annotate(mapper.id(artist.id), Annotation(favoritedAt: SubsonicDate.parse(artist.starred) ?? .now)))
|
||||||
|
}
|
||||||
|
for album in starred.album ?? [] {
|
||||||
|
annotations.append(.annotate(mapper.id(album.id), Annotation(favoritedAt: SubsonicDate.parse(album.starred) ?? .now)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emitRemovals(previous: [EntityKind: Set<String>]) {
|
||||||
|
for (kind, remotes) in previous {
|
||||||
|
for remote in remotes.subtracting(memory.seen[kind] ?? []).sorted() {
|
||||||
|
emit(.remove(kind, mapper.id(remote)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum SubsonicDate {
|
||||||
|
static func parse(_ raw: String?) -> Date? {
|
||||||
|
guard let raw else { return nil }
|
||||||
|
guard let dot = raw.firstIndex(of: ".") else {
|
||||||
|
return try? Date(raw, strategy: .iso8601)
|
||||||
|
}
|
||||||
|
let digits = raw[raw.index(after: dot)...].prefix { $0.isNumber }
|
||||||
|
let rest = raw[raw.index(dot, offsetBy: 1 + digits.count)...]
|
||||||
|
let fraction = String(digits.prefix(3)).padding(toLength: 3, withPad: "0", startingAt: 0)
|
||||||
|
return try? Date(raw[..<dot] + "." + fraction + rest, strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AudioFormat {
|
||||||
|
init?(song: SongJSON) {
|
||||||
|
guard let codec = Codec(suffix: song.suffix) else { return nil }
|
||||||
|
self.init(
|
||||||
|
codec: codec,
|
||||||
|
bitrateKbps: song.bitRate,
|
||||||
|
sampleRate: song.samplingRate,
|
||||||
|
bitDepth: song.bitDepth.flatMap { $0 > 0 ? $0 : nil },
|
||||||
|
channels: song.channelCount ?? 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rank: [Int] {
|
||||||
|
[isLossless ? 1 : 0, sampleRate ?? 0, bitDepth ?? 0, bitrateKbps ?? 0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBetter(than other: AudioFormat?) -> Bool {
|
||||||
|
guard let other else { return true }
|
||||||
|
return other.rank.lexicographicallyPrecedes(rank)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AudioFormat.Codec {
|
||||||
|
init?(suffix: String?) {
|
||||||
|
switch suffix?.lowercased() {
|
||||||
|
case "flac": self = .flac
|
||||||
|
case "alac": self = .alac
|
||||||
|
case "wav": self = .wav
|
||||||
|
case "mp3": self = .mp3
|
||||||
|
case "aac", "m4a": self = .aac
|
||||||
|
case "opus": self = .opus
|
||||||
|
case "ogg": self = .vorbis
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SubsonicMapper: Sendable {
|
||||||
|
let source: SourceID
|
||||||
|
|
||||||
|
func id(_ remote: String) -> EntityID {
|
||||||
|
EntityID(source: source, remote: remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
func artwork(_ remote: String?) -> EntityID? {
|
||||||
|
guard let remote, !remote.isEmpty else { return nil }
|
||||||
|
return id(remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
func artist(_ json: ArtistJSON) -> Artist {
|
||||||
|
Artist(id: id(json.id), name: json.name, sortName: json.sortName, artwork: artwork(json.coverArt), albumCount: json.albumCount ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func track(_ json: SongJSON) -> Track {
|
||||||
|
Track(
|
||||||
|
id: id(json.id),
|
||||||
|
title: json.title,
|
||||||
|
albumID: json.albumId.map(id),
|
||||||
|
albumTitle: json.album ?? "",
|
||||||
|
artistID: json.artistId.map(id),
|
||||||
|
artistName: json.artist ?? "",
|
||||||
|
discNumber: json.discNumber ?? 1,
|
||||||
|
trackNumber: json.track ?? 0,
|
||||||
|
duration: json.duration ?? 0,
|
||||||
|
year: json.year,
|
||||||
|
genre: json.genre,
|
||||||
|
artwork: artwork(json.coverArt),
|
||||||
|
isExplicit: json.explicitStatus == "explicit",
|
||||||
|
formats: AudioFormat(song: json).map { [$0] } ?? []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func album(_ json: AlbumJSON, bestFormat: AudioFormat?) -> Album {
|
||||||
|
Album(
|
||||||
|
id: id(json.id),
|
||||||
|
title: json.name,
|
||||||
|
artistID: json.artistId.map(id),
|
||||||
|
artistName: json.artist ?? "",
|
||||||
|
year: json.year,
|
||||||
|
genre: json.genre,
|
||||||
|
artwork: artwork(json.coverArt),
|
||||||
|
trackCount: json.songCount ?? 0,
|
||||||
|
duration: json.duration ?? 0,
|
||||||
|
addedAt: SubsonicDate.parse(json.created) ?? .distantPast,
|
||||||
|
bestFormat: bestFormat
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func playlist(_ json: PlaylistJSON) -> (Playlist, [PlaylistEntry]) {
|
||||||
|
let playlist = Playlist(
|
||||||
|
id: id(json.id),
|
||||||
|
name: json.name,
|
||||||
|
summary: json.comment ?? "",
|
||||||
|
artwork: artwork(json.coverArt),
|
||||||
|
trackCount: json.songCount ?? json.entry?.count ?? 0,
|
||||||
|
duration: json.duration ?? 0,
|
||||||
|
updatedAt: SubsonicDate.parse(json.changed) ?? .distantPast
|
||||||
|
)
|
||||||
|
let entries = (json.entry ?? []).enumerated().map { index, song in
|
||||||
|
PlaylistEntry(id: "\(json.id):\(index)", track: id(song.id))
|
||||||
|
}
|
||||||
|
return (playlist, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func station(_ json: StationJSON) -> RadioStation? {
|
||||||
|
guard let url = URL(string: json.streamUrl), url.scheme != nil else { return nil }
|
||||||
|
return RadioStation(
|
||||||
|
id: id(json.id),
|
||||||
|
name: json.name,
|
||||||
|
tagline: json.homePageUrl ?? "",
|
||||||
|
streamURL: url,
|
||||||
|
artwork: artwork(json.coverArt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func annotation(_ song: SongJSON) -> Annotation? {
|
||||||
|
let favoritedAt = SubsonicDate.parse(song.starred)
|
||||||
|
let playCount = song.playCount ?? 0
|
||||||
|
guard favoritedAt != nil || playCount > 0 else { return nil }
|
||||||
|
return Annotation(favoritedAt: favoritedAt, playCount: playCount, lastPlayedAt: SubsonicDate.parse(song.played))
|
||||||
|
}
|
||||||
|
|
||||||
|
func lyrics(_ list: [StructuredLyricsJSON]) -> Lyrics? {
|
||||||
|
guard let entry = list.first(where: \.synced) ?? list.first else { return nil }
|
||||||
|
let offset = entry.offset ?? 0
|
||||||
|
let lines = (entry.line ?? []).map { line in
|
||||||
|
LyricLine(time: entry.synced ? Double((line.start ?? 0) + offset) / 1000 : 0, text: line.value)
|
||||||
|
}
|
||||||
|
guard !lines.isEmpty else { return nil }
|
||||||
|
return Lyrics(lines: lines, isSynced: entry.synced)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ArtistJSON: Decodable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let sortName: String?
|
||||||
|
let coverArt: String?
|
||||||
|
let albumCount: Int?
|
||||||
|
let starred: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AlbumJSON: Decodable {
|
||||||
|
let id: String
|
||||||
|
let song: [SongJSON]?
|
||||||
|
let name: String
|
||||||
|
let artist: String?
|
||||||
|
let artistId: String?
|
||||||
|
let coverArt: String?
|
||||||
|
let songCount: Int?
|
||||||
|
let duration: Double?
|
||||||
|
let created: String?
|
||||||
|
let year: Int?
|
||||||
|
let genre: String?
|
||||||
|
let starred: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SongJSON: Decodable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let album: String?
|
||||||
|
let albumId: String?
|
||||||
|
let artist: String?
|
||||||
|
let artistId: String?
|
||||||
|
let track: Int?
|
||||||
|
let discNumber: Int?
|
||||||
|
let year: Int?
|
||||||
|
let genre: String?
|
||||||
|
let coverArt: String?
|
||||||
|
let suffix: String?
|
||||||
|
let bitRate: Int?
|
||||||
|
let samplingRate: Int?
|
||||||
|
let bitDepth: Int?
|
||||||
|
let channelCount: Int?
|
||||||
|
let duration: Double?
|
||||||
|
let playCount: Int?
|
||||||
|
let played: String?
|
||||||
|
let starred: String?
|
||||||
|
let explicitStatus: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlaylistJSON: Decodable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let comment: String?
|
||||||
|
let coverArt: String?
|
||||||
|
let songCount: Int?
|
||||||
|
let duration: Double?
|
||||||
|
let changed: String?
|
||||||
|
let entry: [SongJSON]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StationJSON: Decodable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let streamUrl: String
|
||||||
|
let homePageUrl: String?
|
||||||
|
let coverArt: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StructuredLyricsJSON: Decodable {
|
||||||
|
struct Line: Decodable {
|
||||||
|
let start: Int?
|
||||||
|
let value: String
|
||||||
|
}
|
||||||
|
|
||||||
|
let lang: String?
|
||||||
|
let synced: Bool
|
||||||
|
let offset: Int?
|
||||||
|
let line: [Line]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ArtistsPayload: Decodable {
|
||||||
|
struct Index: Decodable {
|
||||||
|
let artist: [ArtistJSON]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Artists: Decodable {
|
||||||
|
let index: [Index]?
|
||||||
|
}
|
||||||
|
|
||||||
|
let artists: Artists
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AlbumListPayload: Decodable {
|
||||||
|
struct List: Decodable {
|
||||||
|
let album: [AlbumJSON]?
|
||||||
|
}
|
||||||
|
|
||||||
|
let albumList2: List
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AlbumPayload: Decodable {
|
||||||
|
let album: AlbumJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SearchPayload: Decodable {
|
||||||
|
struct Result: Decodable {
|
||||||
|
let song: [SongJSON]?
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchResult3: Result
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlaylistsPayload: Decodable {
|
||||||
|
struct List: Decodable {
|
||||||
|
let playlist: [PlaylistJSON]?
|
||||||
|
}
|
||||||
|
|
||||||
|
let playlists: List
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlaylistPayload: Decodable {
|
||||||
|
let playlist: PlaylistJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StationsPayload: Decodable {
|
||||||
|
struct List: Decodable {
|
||||||
|
let internetRadioStation: [StationJSON]?
|
||||||
|
}
|
||||||
|
|
||||||
|
let internetRadioStations: List
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StarredPayload: Decodable {
|
||||||
|
struct Starred: Decodable {
|
||||||
|
let artist: [ArtistJSON]?
|
||||||
|
let album: [AlbumJSON]?
|
||||||
|
let song: [SongJSON]?
|
||||||
|
}
|
||||||
|
|
||||||
|
let starred2: Starred
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LyricsPayload: Decodable {
|
||||||
|
struct List: Decodable {
|
||||||
|
let structuredLyrics: [StructuredLyricsJSON]?
|
||||||
|
}
|
||||||
|
|
||||||
|
let lyricsList: List
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
import Synchronization
|
||||||
|
|
||||||
|
public struct SubsonicAccount: Codable, Hashable, Sendable {
|
||||||
|
public var baseURL: URL
|
||||||
|
public var username: String
|
||||||
|
|
||||||
|
public init(baseURL: URL, username: String) {
|
||||||
|
self.baseURL = baseURL
|
||||||
|
self.username = username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class SubsonicSource: LibrarySource, Starring, LyricsSource, Scrobbling {
|
||||||
|
public let id: SourceID
|
||||||
|
public let name: String
|
||||||
|
let client: SubsonicClient
|
||||||
|
private let mapper: SubsonicMapper
|
||||||
|
private let memory = Mutex(SubsonicMemory())
|
||||||
|
|
||||||
|
public convenience init(id: SourceID, account: SubsonicAccount, password: String, name: String, session: URLSession = .shared) {
|
||||||
|
let client = SubsonicClient(baseURL: account.baseURL, username: account.username, password: password, session: session)
|
||||||
|
self.init(id: id, name: name, client: client)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(id: SourceID, name: String, client: SubsonicClient) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.client = client
|
||||||
|
mapper = SubsonicMapper(source: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func verify() async throws {
|
||||||
|
try await client.call("ping")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func changes(since _: Cursor?) -> AsyncThrowingStream<Change, Error> {
|
||||||
|
AsyncThrowingStream { continuation in
|
||||||
|
let task = Task {
|
||||||
|
do {
|
||||||
|
let start = Date.now
|
||||||
|
var crawl = SubsonicCrawl(client: client, mapper: mapper) { continuation.yield($0) }
|
||||||
|
let result = try await crawl.run(previous: memory.withLock(\.seen))
|
||||||
|
memory.withLock { $0 = result }
|
||||||
|
continuation.yield(.checkpoint(Cursor(start.ISO8601Format())))
|
||||||
|
continuation.finish()
|
||||||
|
} catch {
|
||||||
|
continuation.finish(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continuation.onTermination = { _ in task.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stream(for track: EntityID) async throws -> StreamRequest {
|
||||||
|
let url = client.url("stream", ["id": track.remote, "format": "raw"])
|
||||||
|
return StreamRequest(resource: ResourceRequest(url: url), format: memory.withLock { $0.formats[track.remote] })
|
||||||
|
}
|
||||||
|
|
||||||
|
public func artwork(_ id: EntityID, pixelSize: Int) async throws -> ResourceRequest {
|
||||||
|
ResourceRequest(url: client.url("getCoverArt", ["id": id.remote, "size": String(pixelSize)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setFavorite(_ isFavorite: Bool, for id: EntityID, kind: EntityKind) async throws {
|
||||||
|
let param = switch kind {
|
||||||
|
case .album: "albumId"
|
||||||
|
case .artist: "artistId"
|
||||||
|
default: "id"
|
||||||
|
}
|
||||||
|
try await client.call(isFavorite ? "star" : "unstar", [param: id.remote])
|
||||||
|
}
|
||||||
|
|
||||||
|
public func lyrics(for track: EntityID) async throws -> Lyrics? {
|
||||||
|
let payload = try await client.call("getLyricsBySongId", ["id": track.remote], as: LyricsPayload.self)
|
||||||
|
return mapper.lyrics(payload.lyricsList.structuredLyrics ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
public func scrobble(_ track: EntityID, at date: Date, submission: Bool) async throws {
|
||||||
|
let time = String(Int(date.timeIntervalSince1970 * 1000))
|
||||||
|
try await client.call("scrobble", ["id": track.remote, "time": time, "submission": submission ? "true" : "false"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum AlbumSort: String, CaseIterable, Sendable {
|
||||||
|
case recentlyAdded, title, artist, year
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SearchResults: Sendable {
|
||||||
|
public var artists: [Artist] = []
|
||||||
|
public var albums: [Album] = []
|
||||||
|
public var tracks: [Track] = []
|
||||||
|
public var playlists: [Playlist] = []
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public var isEmpty: Bool {
|
||||||
|
artists.isEmpty && albums.isEmpty && tracks.isEmpty && playlists.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol Library: Sendable {
|
||||||
|
func apply(_ changes: [Change]) async
|
||||||
|
func cursor(for source: SourceID) async -> Cursor?
|
||||||
|
func setCursor(_ cursor: Cursor, for source: SourceID) async
|
||||||
|
func updates() async -> AsyncStream<Void>
|
||||||
|
|
||||||
|
func artists() async -> [Artist]
|
||||||
|
func artist(_ id: EntityID) async -> Artist?
|
||||||
|
func albums(sortedBy sort: AlbumSort) async -> [Album]
|
||||||
|
func albums(byArtist id: EntityID) async -> [Album]
|
||||||
|
func albums(inGenre genre: String) async -> [Album]
|
||||||
|
func album(_ id: EntityID) async -> Album?
|
||||||
|
func tracks() async -> [Track]
|
||||||
|
func tracks(inAlbum id: EntityID) async -> [Track]
|
||||||
|
func tracks(byArtist id: EntityID) async -> [Track]
|
||||||
|
func tracks(inGenre genre: String) async -> [Track]
|
||||||
|
func tracks(inPlaylist id: EntityID) async -> [Track]
|
||||||
|
func playlists() async -> [Playlist]
|
||||||
|
func stations() async -> [RadioStation]
|
||||||
|
func genres() async -> [Genre]
|
||||||
|
|
||||||
|
func recentlyPlayed(limit: Int) async -> [Album]
|
||||||
|
func mostPlayed(limit: Int) async -> [Album]
|
||||||
|
func favoriteTracks() async -> [Track]
|
||||||
|
func favorites() async -> Set<EntityID>
|
||||||
|
func setFavorite(_ isFavorite: Bool, for id: EntityID) async
|
||||||
|
func recordPlay(of track: Track, at date: Date) async
|
||||||
|
|
||||||
|
func search(_ query: String) async -> SearchResults
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public actor MemoryLibrary: Library {
|
||||||
|
private var artistsByID: [EntityID: Artist] = [:]
|
||||||
|
private var albumsByID: [EntityID: Album] = [:]
|
||||||
|
private var tracksByID: [EntityID: Track] = [:]
|
||||||
|
private var playlistsByID: [EntityID: Playlist] = [:]
|
||||||
|
private var entriesByPlaylist: [EntityID: [PlaylistEntry]] = [:]
|
||||||
|
private var stationsByID: [EntityID: RadioStation] = [:]
|
||||||
|
private var annotations: [EntityID: Annotation] = [:]
|
||||||
|
private var cursors: [SourceID: Cursor] = [:]
|
||||||
|
private var observers: [UUID: AsyncStream<Void>.Continuation] = [:]
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func apply(_ changes: [Change]) {
|
||||||
|
for change in changes {
|
||||||
|
switch change {
|
||||||
|
case let .upsert(.artist(artist)): artistsByID[artist.id] = artist
|
||||||
|
case let .upsert(.album(album)): albumsByID[album.id] = album
|
||||||
|
case let .upsert(.track(track)): tracksByID[track.id] = track
|
||||||
|
case let .upsert(.playlist(playlist, entries)):
|
||||||
|
playlistsByID[playlist.id] = playlist
|
||||||
|
entriesByPlaylist[playlist.id] = entries
|
||||||
|
case let .upsert(.station(station)): stationsByID[station.id] = station
|
||||||
|
case let .remove(kind, id): remove(kind, id)
|
||||||
|
case let .annotate(id, annotation): annotations[id] = annotation
|
||||||
|
case .checkpoint: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func remove(_ kind: EntityKind, _ id: EntityID) {
|
||||||
|
switch kind {
|
||||||
|
case .artist: artistsByID[id] = nil
|
||||||
|
case .album: albumsByID[id] = nil
|
||||||
|
case .track: tracksByID[id] = nil
|
||||||
|
case .playlist:
|
||||||
|
playlistsByID[id] = nil
|
||||||
|
entriesByPlaylist[id] = nil
|
||||||
|
case .station: stationsByID[id] = nil
|
||||||
|
}
|
||||||
|
annotations[id] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func cursor(for source: SourceID) -> Cursor? {
|
||||||
|
cursors[source]
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setCursor(_ cursor: Cursor, for source: SourceID) {
|
||||||
|
cursors[source] = cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updates() -> AsyncStream<Void> {
|
||||||
|
let key = UUID()
|
||||||
|
let (stream, continuation) = AsyncStream<Void>.makeStream(bufferingPolicy: .bufferingNewest(1))
|
||||||
|
observers[key] = continuation
|
||||||
|
continuation.onTermination = { [weak self] _ in
|
||||||
|
Task { await self?.removeObserver(key) }
|
||||||
|
}
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeObserver(_ key: UUID) {
|
||||||
|
observers[key] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notify() {
|
||||||
|
observers.values.forEach { $0.yield() }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func artists() -> [Artist] {
|
||||||
|
artistsByID.values.sorted { $0.sortName.localizedStandardCompare($1.sortName) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func artist(_ id: EntityID) -> Artist? {
|
||||||
|
artistsByID[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
public func albums(sortedBy sort: AlbumSort) -> [Album] {
|
||||||
|
let all = Array(albumsByID.values)
|
||||||
|
switch sort {
|
||||||
|
case .recentlyAdded: return all.sorted { $0.addedAt > $1.addedAt }
|
||||||
|
case .title: return all.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
|
||||||
|
case .artist:
|
||||||
|
return all.sorted {
|
||||||
|
let order = $0.artistName.localizedStandardCompare($1.artistName)
|
||||||
|
return order == .orderedSame ? ($0.year ?? 0) < ($1.year ?? 0) : order == .orderedAscending
|
||||||
|
}
|
||||||
|
case .year: return all.sorted { ($0.year ?? 0) > ($1.year ?? 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func albums(byArtist id: EntityID) -> [Album] {
|
||||||
|
albumsByID.values.filter { $0.artistID == id }.sorted { ($0.year ?? 0) > ($1.year ?? 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func albums(inGenre genre: String) -> [Album] {
|
||||||
|
albums(sortedBy: .artist).filter { $0.genre == genre }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func album(_ id: EntityID) -> Album? {
|
||||||
|
albumsByID[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
public func tracks() -> [Track] {
|
||||||
|
tracksByID.values.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func tracks(inAlbum id: EntityID) -> [Track] {
|
||||||
|
tracksByID.values.filter { $0.albumID == id }.sorted { ($0.discNumber, $0.trackNumber) < ($1.discNumber, $1.trackNumber) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func tracks(byArtist id: EntityID) -> [Track] {
|
||||||
|
tracksByID.values.filter { $0.artistID == id }
|
||||||
|
.sorted { (annotations[$0.id]?.playCount ?? 0, $1.title) > (annotations[$1.id]?.playCount ?? 0, $0.title) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func tracks(inGenre genre: String) -> [Track] {
|
||||||
|
tracksByID.values.filter { $0.genre == genre }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func tracks(inPlaylist id: EntityID) -> [Track] {
|
||||||
|
(entriesByPlaylist[id] ?? []).compactMap { tracksByID[$0.track] }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func playlists() -> [Playlist] {
|
||||||
|
playlistsByID.values.sorted { $0.updatedAt > $1.updatedAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stations() -> [RadioStation] {
|
||||||
|
stationsByID.values.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func genres() -> [Genre] {
|
||||||
|
Dictionary(grouping: albumsByID.values.filter { $0.genre != nil }, by: { $0.genre ?? "" })
|
||||||
|
.map { Genre(name: $0.key, albumCount: $0.value.count) }
|
||||||
|
.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func albumsRanked(limit: Int, by score: (Annotation) -> Double?) -> [Album] {
|
||||||
|
var best: [EntityID: Double] = [:]
|
||||||
|
for (id, annotation) in annotations {
|
||||||
|
guard let value = score(annotation), let album = tracksByID[id]?.albumID else { continue }
|
||||||
|
best[album] = max(best[album] ?? -.infinity, value)
|
||||||
|
}
|
||||||
|
return best.sorted { $0.value > $1.value }.prefix(limit).compactMap { albumsByID[$0.key] }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func recentlyPlayed(limit: Int) -> [Album] {
|
||||||
|
albumsRanked(limit: limit) { $0.lastPlayedAt?.timeIntervalSince1970 }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func mostPlayed(limit: Int) -> [Album] {
|
||||||
|
albumsRanked(limit: limit) { $0.playCount > 0 ? Double($0.playCount) : nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func favoriteTracks() -> [Track] {
|
||||||
|
tracksByID.values
|
||||||
|
.filter { annotations[$0.id]?.favoritedAt != nil }
|
||||||
|
.sorted { (annotations[$0.id]?.favoritedAt ?? .distantPast) > (annotations[$1.id]?.favoritedAt ?? .distantPast) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func favorites() -> Set<EntityID> {
|
||||||
|
Set(annotations.filter { $0.value.favoritedAt != nil }.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setFavorite(_ isFavorite: Bool, for id: EntityID) {
|
||||||
|
annotations[id, default: Annotation()].favoritedAt = isFavorite ? Date() : nil
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func recordPlay(of track: Track, at date: Date) {
|
||||||
|
annotations[track.id, default: Annotation()].playCount += 1
|
||||||
|
annotations[track.id, default: Annotation()].lastPlayedAt = date
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func search(_ query: String) -> SearchResults {
|
||||||
|
let needle = query.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !needle.isEmpty else { return SearchResults() }
|
||||||
|
func matches(_ text: String) -> Bool {
|
||||||
|
text.range(of: needle, options: [.caseInsensitive, .diacriticInsensitive]) != nil
|
||||||
|
}
|
||||||
|
var results = SearchResults()
|
||||||
|
results.artists = artists().filter { matches($0.name) }
|
||||||
|
results.albums = albums(sortedBy: .title).filter { matches($0.title) }
|
||||||
|
results.tracks = Array(tracks().filter { matches($0.title) }.prefix(50))
|
||||||
|
results.playlists = playlists().filter { matches($0.name) }
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Domain
|
||||||
|
import Store
|
||||||
|
|
||||||
|
public actor SyncEngine {
|
||||||
|
private let library: any Library
|
||||||
|
private let batchSize = 500
|
||||||
|
|
||||||
|
public init(library: any Library) {
|
||||||
|
self.library = library
|
||||||
|
}
|
||||||
|
|
||||||
|
public func sync(_ source: any LibrarySource) async throws {
|
||||||
|
do {
|
||||||
|
try await pull(source, since: library.cursor(for: source.id))
|
||||||
|
} catch SourceError.cannotCalculateChanges {
|
||||||
|
try await pull(source, since: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pull(_ source: any LibrarySource, since cursor: Cursor?) async throws {
|
||||||
|
var batch: [Change] = []
|
||||||
|
for try await change in source.changes(since: cursor) {
|
||||||
|
if case let .checkpoint(next) = change {
|
||||||
|
await library.apply(batch)
|
||||||
|
batch.removeAll(keepingCapacity: true)
|
||||||
|
await library.setCursor(next, for: source.id)
|
||||||
|
} else {
|
||||||
|
batch.append(change)
|
||||||
|
if batch.count >= batchSize {
|
||||||
|
await library.apply(batch)
|
||||||
|
batch.removeAll(keepingCapacity: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !batch.isEmpty {
|
||||||
|
await library.apply(batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import Domain
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
struct LyricsTests {
|
||||||
|
@Test func parsesSyncedLines() {
|
||||||
|
let lyrics = Lyrics(lrc: "[00:11.39]Первая\n[00:13.61][01:20.18]Повтор\n[02:00.75]\n")
|
||||||
|
#expect(lyrics?.isSynced == true)
|
||||||
|
#expect(lyrics?.lines.map(\.text) == ["Первая", "Повтор", "Повтор", ""])
|
||||||
|
#expect(lyrics?.lines[2].time == 80.18)
|
||||||
|
#expect(lyrics?.index(at: 5) == nil)
|
||||||
|
#expect(lyrics?.index(at: 12) == 0)
|
||||||
|
#expect(lyrics?.index(at: 90) == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func fallsBackToPlainText() {
|
||||||
|
let lyrics = Lyrics(lrc: "Просто строка\nЕщё одна")
|
||||||
|
#expect(lyrics?.isSynced == false)
|
||||||
|
#expect(lyrics?.lines.count == 2)
|
||||||
|
#expect(Lyrics(lrc: "") == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
@testable import Playback
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
actor RecordingEngine: OutputEngine {
|
||||||
|
nonisolated let events: AsyncStream<OutputEvent>
|
||||||
|
nonisolated let continuation: AsyncStream<OutputEvent>.Continuation
|
||||||
|
private(set) var loaded: [QueueItem.ID] = []
|
||||||
|
private(set) var prepared: [QueueItem.ID?] = []
|
||||||
|
|
||||||
|
init() {
|
||||||
|
(events, continuation) = AsyncStream<OutputEvent>.makeStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(_ item: QueueItem, at _: TimeInterval) {
|
||||||
|
loaded.append(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepare(_ item: QueueItem?) {
|
||||||
|
prepared.append(item?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func play() {}
|
||||||
|
func pause() {}
|
||||||
|
func seek(to _: TimeInterval) {}
|
||||||
|
func stop() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class FakeClock: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var instant = ContinuousClock.now
|
||||||
|
|
||||||
|
var now: ContinuousClock.Instant {
|
||||||
|
lock.withLock { instant }
|
||||||
|
}
|
||||||
|
|
||||||
|
func advance(_ seconds: Double) {
|
||||||
|
lock.withLock { instant += .seconds(seconds) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tracks(_ count: Int) -> [Track] {
|
||||||
|
let source = SourceID()
|
||||||
|
return (0 ..< count).map {
|
||||||
|
Track(
|
||||||
|
id: EntityID(source: source, remote: "\($0)"),
|
||||||
|
title: "Track \($0)",
|
||||||
|
albumID: nil,
|
||||||
|
albumTitle: "",
|
||||||
|
artistID: nil,
|
||||||
|
artistName: "",
|
||||||
|
duration: 200
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func queueStartsAtIndexAndAnchorsPosition() async {
|
||||||
|
let clock = FakeClock()
|
||||||
|
let session = PlaybackSession(engine: RecordingEngine()) { clock.now }
|
||||||
|
await session.send(.setQueue(tracks: tracks(5), startIndex: 2, shuffled: false))
|
||||||
|
clock.advance(10)
|
||||||
|
let state = await session.snapshot
|
||||||
|
#expect(state.current?.track.title == "Track 2")
|
||||||
|
#expect(state.history.count == 2 && state.upcoming.count == 2)
|
||||||
|
#expect(state.status == .playing)
|
||||||
|
#expect(abs(state.anchor.position(at: clock.now) - 10) < 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func pauseFreezesAnchor() async {
|
||||||
|
let clock = FakeClock()
|
||||||
|
let session = PlaybackSession(engine: RecordingEngine()) { clock.now }
|
||||||
|
await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false))
|
||||||
|
clock.advance(30)
|
||||||
|
await session.send(.pause)
|
||||||
|
clock.advance(30)
|
||||||
|
let state = await session.snapshot
|
||||||
|
#expect(abs(state.anchor.position(at: clock.now) - 30) < 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func previousRestartsAfterThresholdAndGoesBackBefore() async {
|
||||||
|
let clock = FakeClock()
|
||||||
|
let session = PlaybackSession(engine: RecordingEngine()) { clock.now }
|
||||||
|
await session.send(.setQueue(tracks: tracks(3), startIndex: 1, shuffled: false))
|
||||||
|
clock.advance(10)
|
||||||
|
await session.send(.previous)
|
||||||
|
#expect(await session.snapshot.current?.track.title == "Track 1")
|
||||||
|
await session.send(.previous)
|
||||||
|
#expect(await session.snapshot.current?.track.title == "Track 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func queueEditsAddressItemsByID() async {
|
||||||
|
let session = PlaybackSession(engine: RecordingEngine())
|
||||||
|
await session.send(.setQueue(tracks: tracks(4), startIndex: 0, shuffled: false))
|
||||||
|
let upcoming = await session.snapshot.upcoming
|
||||||
|
await session.send(.move(item: upcoming[2].id, after: nil))
|
||||||
|
await session.send(.remove(upcoming[0].id))
|
||||||
|
#expect(await session.snapshot.upcoming.map(\.track.title) == ["Track 3", "Track 2"])
|
||||||
|
await session.send(.jump(to: upcoming[1].id))
|
||||||
|
let state = await session.snapshot
|
||||||
|
#expect(state.current?.track.title == "Track 2" && state.upcoming.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func finishedTrackAdvancesAndStopsAtEndWithoutRepeat() async throws {
|
||||||
|
let engine = RecordingEngine()
|
||||||
|
let session = PlaybackSession(engine: engine)
|
||||||
|
let states = await session.states()
|
||||||
|
await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false))
|
||||||
|
for expected in [("Track 1", PlaybackStatus.playing), ("Track 0", .paused)] {
|
||||||
|
let current = try #require(await session.snapshot.current)
|
||||||
|
engine.continuation.yield(.finished(current.id))
|
||||||
|
for await state in states where state.current?.id != current.id {
|
||||||
|
#expect(state.current?.track.title == expected.0 && state.status == expected.1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unshuffleRestoresOriginalOrder() async {
|
||||||
|
let session = PlaybackSession(engine: RecordingEngine())
|
||||||
|
await session.send(.setQueue(tracks: tracks(8), startIndex: 0, shuffled: false))
|
||||||
|
await session.send(.setShuffle(true))
|
||||||
|
await session.send(.setShuffle(false))
|
||||||
|
#expect(await session.snapshot.upcoming.map(\.track.title) == (1 ..< 8).map { "Track \($0)" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func prepareFollowsHeadOfUpcoming() async {
|
||||||
|
let engine = RecordingEngine()
|
||||||
|
let session = PlaybackSession(engine: engine)
|
||||||
|
await session.send(.setQueue(tracks: tracks(3), startIndex: 0, shuffled: false))
|
||||||
|
var state = await session.snapshot
|
||||||
|
#expect(await engine.prepared == [state.upcoming[0].id])
|
||||||
|
await session.send(.next)
|
||||||
|
state = await session.snapshot
|
||||||
|
#expect(await engine.prepared.last == state.upcoming[0].id)
|
||||||
|
await session.send(.next)
|
||||||
|
#expect(await engine.prepared.last == .some(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func anchorEventReanchorsCurrentAndIgnoresStaleID() async throws {
|
||||||
|
let clock = FakeClock()
|
||||||
|
let engine = RecordingEngine()
|
||||||
|
let session = PlaybackSession(engine: engine) { clock.now }
|
||||||
|
let states = await session.states()
|
||||||
|
await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false))
|
||||||
|
let current = try #require(await session.snapshot.current)
|
||||||
|
engine.continuation.yield(.anchor(UUID(), position: 50, rate: 1))
|
||||||
|
engine.continuation.yield(.anchor(current.id, position: 70, rate: 0))
|
||||||
|
for await state in states where state.anchor.rate == 0 {
|
||||||
|
#expect(state.status == .playing && state.current?.id == current.id)
|
||||||
|
#expect(abs(state.anchor.position(at: clock.now) - 70) < 0.001)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func failedTrackAdvancesToNext() async throws {
|
||||||
|
let engine = RecordingEngine()
|
||||||
|
let session = PlaybackSession(engine: engine)
|
||||||
|
let states = await session.states()
|
||||||
|
await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false))
|
||||||
|
let current = try #require(await session.snapshot.current)
|
||||||
|
engine.continuation.yield(.failed(current.id))
|
||||||
|
for await state in states where state.current?.id != current.id {
|
||||||
|
#expect(state.current?.track.title == "Track 1" && state.status == .playing)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func everyTrackFailingStopsInsteadOfLooping() async throws {
|
||||||
|
let engine = RecordingEngine()
|
||||||
|
let session = PlaybackSession(engine: engine)
|
||||||
|
let states = await session.states()
|
||||||
|
await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false))
|
||||||
|
await session.send(.setRepeat(.all))
|
||||||
|
for _ in 0 ..< 3 {
|
||||||
|
let current = try #require(await session.snapshot.current)
|
||||||
|
engine.continuation.yield(.failed(current.id))
|
||||||
|
for await state in states where state.current?.id != current.id || state.status == .paused {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let state = await session.snapshot
|
||||||
|
#expect(state.status == .paused && state.anchor.rate == 0)
|
||||||
|
#expect(await engine.loaded.count == 2)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
@testable import SourceSubsonic
|
||||||
|
|
||||||
|
struct FixtureTransport: Sendable {
|
||||||
|
var droppedAlbum: String?
|
||||||
|
|
||||||
|
static func fixture(_ name: String) throws -> Data {
|
||||||
|
let url = try unwrap(Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Fixtures"))
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func empty(_ key: String) -> Data {
|
||||||
|
Data(#"{"subsonic-response":{"status":"ok","version":"1.16.1","\#(key)":{}}}"#.utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func method(of request: URLRequest) -> (String, [String: String]) {
|
||||||
|
let url = request.url ?? URL(filePath: "/")
|
||||||
|
let method = url.lastPathComponent.replacingOccurrences(of: ".view", with: "")
|
||||||
|
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||||
|
return (method, Dictionary(items.map { ($0.name, $0.value ?? "") }) { $1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
func callAsFunction(_ request: URLRequest) async throws -> (Data, URLResponse) {
|
||||||
|
let (method, params) = Self.method(of: request)
|
||||||
|
let response = HTTPURLResponse(url: request.url ?? URL(filePath: "/"), statusCode: 200, httpVersion: nil, headerFields: nil)
|
||||||
|
return try (data(for: method, params: params), response ?? URLResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func data(for method: String, params: [String: String]) throws -> Data {
|
||||||
|
switch method {
|
||||||
|
case "getArtists": try Self.fixture("artists")
|
||||||
|
case "search3" where params["songOffset"] == "0": try Self.fixture("search3All")
|
||||||
|
case "search3": Self.empty("searchResult3")
|
||||||
|
case "getAlbumList2" where params["offset"] == "0": try albums()
|
||||||
|
case "getAlbumList2": Self.empty("albumList2")
|
||||||
|
case "getPlaylists": try Self.fixture("playlists")
|
||||||
|
case "getPlaylist": try Self.fixture("playlist")
|
||||||
|
case "getInternetRadioStations": try Self.fixture("radioStations")
|
||||||
|
case "getStarred2": try Self.fixture("starred2")
|
||||||
|
case "getLyricsBySongId": try Self.fixture("lyricsEmpty")
|
||||||
|
default: try Self.fixture("ping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func albums() throws -> Data {
|
||||||
|
let data = try Self.fixture("albumList2All")
|
||||||
|
guard let droppedAlbum else { return data }
|
||||||
|
var root = try unwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||||
|
var response = try unwrap(root["subsonic-response"] as? [String: Any])
|
||||||
|
var list = try unwrap(response["albumList2"] as? [String: Any])
|
||||||
|
let albums = try unwrap(list["album"] as? [[String: Any]])
|
||||||
|
list["album"] = albums.filter { $0["id"] as? String != droppedAlbum }
|
||||||
|
response["albumList2"] = list
|
||||||
|
root["subsonic-response"] = response
|
||||||
|
return try JSONSerialization.data(withJSONObject: root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SubsonicSource {
|
||||||
|
static func fixture(transport: FixtureTransport = FixtureTransport(), id: SourceID = SourceID()) -> SubsonicSource {
|
||||||
|
let client = SubsonicClient(
|
||||||
|
baseURL: URL(string: "https://music.example.test")!,
|
||||||
|
username: "h",
|
||||||
|
password: "secret",
|
||||||
|
transport: { try await transport($0) }
|
||||||
|
)
|
||||||
|
return SubsonicSource(id: id, name: "Test", client: client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collect() async throws -> [Change] {
|
||||||
|
var collected: [Change] = []
|
||||||
|
for try await change in changes(since: nil) {
|
||||||
|
collected.append(change)
|
||||||
|
}
|
||||||
|
return collected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unwrap<T>(_ value: T?) throws -> T {
|
||||||
|
guard let value else { throw FixtureError.missing }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum FixtureError: Error {
|
||||||
|
case missing
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"album":{"id":"5sPoyWVCZcD9yiA97o0FbY","name":"Shh!","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-5sPoyWVCZcD9yiA97o0FbY_6aa31dbd","songCount":14,"duration":1590,"playCount":31,"created":"2026-09-10T21:16:06.756840041Z","year":2026,"genre":"Alternative Rap","played":"2026-09-20T20:21:27.738621024Z","userRating":0,"genres":[{"name":"Alternative Rap"}],"musicBrainzId":"","isCompilation":false,"sortName":"shh!","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":"","song":[{"id":"2XjfMC2rf5m85Ln844iLxe","parent":"5sPoyWVCZcD9yiA97o0FbY","isDir":false,"title":"Shh!","album":"Shh!","artist":"Kai Angel","track":1,"year":2026,"genre":"Alternative Rap","coverArt":"mf-2XjfMC2rf5m85Ln844iLxe_6aa31dbc","size":1413073,"contentType":"audio/flac","suffix":"flac","duration":60,"bitRate":173,"path":"Kai Angel/Shh!/01-01 - Shh!.flac","playCount":1,"discNumber":1,"created":"2026-09-10T21:16:06.783726686Z","albumId":"5sPoyWVCZcD9yiA97o0FbY","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-09-10T21:16:40.955Z","bpm":0,"comment":"","sortName":"shh!","mediaType":"song","musicBrainzId":"","isrc":["FRX762654565"],"genres":[{"name":"Alternative Rap"}],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":16,"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","albumArtists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayAlbumArtist":"Kai Angel","contributors":[{"role":"performer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}},{"role":"composer","artist":{"id":"1iovHlVfkopxgRWmOVFcIq","name":"Kai Angel \u0026 Dmitri Itskov"}}],"displayComposer":"Kai Angel \u0026 Dmitri Itskov","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"PpRAkqAajLOjMdMRIP8pEM","parent":"5sPoyWVCZcD9yiA97o0FbY","isDir":false,"title":"chateau","album":"Shh!","artist":"Kai Angel","track":2,"year":2026,"genre":"Alternative Rap","coverArt":"mf-PpRAkqAajLOjMdMRIP8pEM_6aa31dbc","size":16291360,"contentType":"audio/flac","suffix":"flac","duration":116,"bitRate":1111,"path":"Kai Angel/Shh!/01-02 - chateau.flac","playCount":2,"discNumber":1,"created":"2026-09-10T21:16:06.769095977Z","albumId":"5sPoyWVCZcD9yiA97o0FbY","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-09-11T19:32:25.09Z","bpm":0,"comment":"","sortName":"chateau","mediaType":"song","musicBrainzId":"","isrc":["FRX762654566"],"genres":[{"name":"Alternative Rap"}],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":16,"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","albumArtists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayAlbumArtist":"Kai Angel","contributors":[{"role":"performer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}},{"role":"composer","artist":{"id":"2aKpbUZ0l9FuDeYo57E7Jk","name":"Dmitri Itskov"}}],"displayComposer":"Dmitri Itskov","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"1vD0jhSIaCcOOTHp8gCB6o","parent":"5sPoyWVCZcD9yiA97o0FbY","isDir":false,"title":"gladiator","album":"Shh!","artist":"Kai Angel","track":3,"year":2026,"genre":"Alternative Rap","coverArt":"mf-1vD0jhSIaCcOOTHp8gCB6o_6aa31dbc","size":14395244,"contentType":"audio/flac","suffix":"flac","duration":103,"bitRate":1104,"path":"Kai Angel/Shh!/01-03 - gladiator.flac","playCount":2,"discNumber":1,"created":"2026-09-10T21:16:06.785279872Z","albumId":"5sPoyWVCZcD9yiA97o0FbY","artistId":"2FXyezR7e5yT2ZwTDFF5ps","type":"music","played":"2026-09-11T19:24:55.741Z","bpm":0,"comment":"","sortName":"gladiator","mediaType":"song","musicBrainzId":"","isrc":["FRX762654567"],"genres":[{"name":"Alternative Rap"}],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":16,"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","albumArtists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayAlbumArtist":"Kai Angel","contributors":[{"role":"performer","artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}},{"role":"composer","artist":{"id":"2aKpbUZ0l9FuDeYo57E7Jk","name":"Dmitri Itskov"}}],"displayComposer":"Dmitri Itskov","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"uLFOBkciZUwJ99SMUB3RTo","parent":"5sPoyWVCZcD9yiA97o0FbY","isDir":false,"title":"OFFICE SONG","album":"Shh!","artist":"Kai Angel","track":4,"year":2026,"genre":"Alternative Rap","coverArt":"mf-uLFOBkciZUwJ99SMUB3RTo_6aa31dbc","size":14442048,"contentType":"audio/flac","suffix":"flac","duration":102,"bitRate":1114,"path":"Kai Angel/Shh!/01-04 - OFFICE SONG.flac","playCount":2,"discNumber":1,"created":"2026-09-10TLine truncated
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"albumList2":{"album":[{"id":"0OpO6hd2TejwnaHREMESA6","name":"#4","artist":"Ling tosite sigure","artistId":"02kw7dvTBVLsOPfd2Cm8oG","coverArt":"al-0OpO6hd2TejwnaHREMESA6_6a9b1a1a","songCount":10,"duration":2742,"created":"2026-09-04T19:23:07.475677821Z","year":2005,"genre":"Rock","userRating":0,"genres":[{"name":"Rock"}],"musicBrainzId":"","isCompilation":false,"sortName":"#4","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"02kw7dvTBVLsOPfd2Cm8oG","name":"Ling tosite sigure"}],"displayArtist":"Ling tosite sigure","explicitStatus":"","version":""},{"id":"3Lnw1qfghHkbGmUBOCeKQY","name":"#djcore","artist":"never die","artistId":"1RlholhxfO8YebLSmsyb4o","coverArt":"al-3Lnw1qfghHkbGmUBOCeKQY_6a8767a6","songCount":6,"duration":622,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-20T15:28:23.425686673Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"#djcore","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","explicitStatus":"","version":""},{"id":"1gvF7VcdmJjLnYNQiFqvfm","name":"(((","artist":"темныи принц","artistId":"1EKMdgwWJQWKKwMurtJ1up","coverArt":"al-1gvF7VcdmJjLnYNQiFqvfm_6a87651c","songCount":1,"duration":66,"playCount":2,"created":"2026-08-20T05:12:32.079858766Z","year":2024,"played":"2026-08-20T13:57:31.661Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":true,"sortName":"(((","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1EKMdgwWJQWKKwMurtJ1up","name":"темныи принц"}],"displayArtist":"темныи принц","explicitStatus":"","version":""}]}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"albumList2":{"album":[{"id":"0OpO6hd2TejwnaHREMESA6","name":"#4","artist":"Ling tosite sigure","artistId":"02kw7dvTBVLsOPfd2Cm8oG","coverArt":"al-0OpO6hd2TejwnaHREMESA6_6a9b1a1a","songCount":10,"duration":2742,"created":"2026-09-04T19:23:07.475677821Z","year":2005,"genre":"Rock","userRating":0,"genres":[{"name":"Rock"}],"musicBrainzId":"","isCompilation":false,"sortName":"#4","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"02kw7dvTBVLsOPfd2Cm8oG","name":"Ling tosite sigure"}],"displayArtist":"Ling tosite sigure","explicitStatus":"","version":""},{"id":"3Lnw1qfghHkbGmUBOCeKQY","name":"#djcore","artist":"never die","artistId":"1RlholhxfO8YebLSmsyb4o","coverArt":"al-3Lnw1qfghHkbGmUBOCeKQY_6a8767a6","songCount":6,"duration":622,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-20T15:28:23.425686673Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"#djcore","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1RlholhxfO8YebLSmsyb4o","name":"never die"}],"displayArtist":"never die","explicitStatus":"","version":""},{"id":"1gvF7VcdmJjLnYNQiFqvfm","name":"(((","artist":"темныи принц","artistId":"1EKMdgwWJQWKKwMurtJ1up","coverArt":"al-1gvF7VcdmJjLnYNQiFqvfm_6a87651c","songCount":1,"duration":66,"playCount":2,"created":"2026-08-20T05:12:32.079858766Z","year":2024,"played":"2026-08-20T13:57:31.661Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":true,"sortName":"(((","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"1EKMdgwWJQWKKwMurtJ1up","name":"темныи принц"}],"displayArtist":"темныи принц","explicitStatus":"","version":""},{"id":"66z308DnU98p9YLSAnSXiz","name":"*** TRIVIAL","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-66z308DnU98p9YLSAnSXiz_6a87a4c9","songCount":1,"duration":161,"created":"2026-08-21T01:07:26.491226036Z","year":2022,"userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"*** trivial","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"7iNtEoUDPLI2jHAhvt68Nb","name":"1705","artist":"KVESTAR","artistId":"5p11l3dOsKjMlcd8HBxwIQ","coverArt":"al-7iNtEoUDPLI2jHAhvt68Nb_6a87687f","songCount":1,"duration":99,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"1705","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5p11l3dOsKjMlcd8HBxwIQ","name":"KVESTAR"}],"displayArtist":"KVESTAR","explicitStatus":"","version":""},{"id":"6PPofaYbL9xveLL59B6jMq","name":"2009","artist":"дмитрий уткин","artistId":"7v0nmjPcxsKDPInhOlBnNs","coverArt":"al-6PPofaYbL9xveLL59B6jMq_6a8767e1","songCount":1,"duration":130,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"2009","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"7v0nmjPcxsKDPInhOlBnNs","name":"дмитрий уткин"}],"displayArtist":"дмитрий уткин","explicitStatus":"","version":""},{"id":"11aFoKRi2asMpeCFuyvQhY","name":"2DWRLD","artist":"Midix","artistId":"6czjE6pPpWVuxiDRbLPGWi","coverArt":"al-11aFoKRi2asMpeCFuyvQhY_6a876774","songCount":1,"duration":155,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"2dwrld","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"6czjE6pPpWVuxiDRbLPGWi","name":"Midix"}],"displayArtist":"Midix","explicitStatus":"","version":""},{"id":"2YxgR9lkWsMguDKAAR2dFN","name":"3","artist":"Hikerii","artistId":"5nixPYpmgfAPMYSqsAEk6K","coverArt":"al-2YxgR9lkWsMguDKAAR2dFN_6a876a4b","songCount":1,"duration":97,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"3","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"5nixPYpmgfAPMYSqsAEk6K","name":"Hikerii"}],"displayArtist":"Hikerii","explicitStatus":"","version":""},{"id":"4QGXYX9mmibCkcbBXDBvdT","name":"8 миля","artist":"madk1d","artistId":"7Dy79oui9xLw7S5an2x0nk","coverArt":"al-4QGXYX9mmibCkcbBXDBvdT_6a87688e","songCount":1,"duration":115,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId"Line truncated
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"artist":{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel","coverArt":"ar-2FXyezR7e5yT2ZwTDFF5ps_0","albumCount":30,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTJGWHllelI3ZTV5VDJad1RERkY1cHNfMCIsImlzcyI6Ik5EIn0.gfjo_ScFAJgCVz4wkF41t5QHH25dB1xTduLnE2wG-4E?size=600","musicBrainzId":"a78be557-1019-48bc-a4ed-6da7a50841ef","sortName":"kai angel","roles":["albumartist","artist","composer","performer","producer","maincredit"],"album":[{"id":"2GKWcFB67yeOQDFRDvFvke","name":" TRIVIAL","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-2GKWcFB67yeOQDFRDvFvke_6a876a31","songCount":1,"duration":161,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"trivial","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"18BXzMwofe1WsshrIg4fNr","name":"FAKE LOVE","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-18BXzMwofe1WsshrIg4fNr_6a8769e4","songCount":1,"duration":134,"playCount":8,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-20T13:53:10.826Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"fake love","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"3CIa4vI8ORVCOG8sc3ah4y","name":"HEAVY METAL 2","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-3CIa4vI8ORVCOG8sc3ah4y_6a876a0d","songCount":21,"duration":2324,"playCount":11,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-09-20T20:23:28.955339273Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"heavy metal 2","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"2lzu0A9LaO4U2V1JZmoX2l","name":"Madonna","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-2lzu0A9LaO4U2V1JZmoX2l_6a876a0f","songCount":1,"duration":118,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"madonna","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"2TX1qY1w1mRsYDTqCeqJRR","name":"jennifer's body","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-2TX1qY1w1mRsYDTqCeqJRR_6a8769e5","songCount":1,"duration":108,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"jennifer's body","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"0N6lvnldi2S79gYY1bdqPN","name":"lovesong","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-0N6lvnldi2S79gYY1bdqPN_6a876a21","songCount":1,"duration":107,"created":"2026-08-20T05:12:32.079858766Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"lovesong","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"0ZoaVvVl3AWdVhSKWjxYrg","name":"quiet turn up","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-0ZoaVvVl3AWdVhSKWjxYrg_6a8769b9","songCount":1,"duration":110,"playCount":1,"created":"2026-08-20T05:12:32.079858766Z","played":"2026-08-26T18:39:30.156Z","userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"quiet turn up","discTitles":[],"originalReleaseDate":{},"releaseDate":{},"releaseTypes":[],"recordLabels":[],"moods":[],"artists":[{"id":"2FXyezR7e5yT2ZwTDFF5ps","name":"Kai Angel"}],"displayArtist":"Kai Angel","explicitStatus":"","version":""},{"id":"66z308DnU98p9YLSAnSXiz","name":"*** TRIVIAL","artist":"Kai Angel","artistId":"2FXyezR7e5yT2ZwTDFF5ps","coverArt":"al-66z308DnU98p9YLSAnSXiz_6a87a4c9","songCount":1,"duration":161,"created":"2026-08-21T01:07:26.491226036Z","year":2022,"userRating":0,"genres":[],"musicBrainzId":"","isCompilation":false,"sortName":"*** trivial","discTitles":[],"originalReleasLine truncated
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"artists":{"index":[{"name":"#","artist":[{"id":"62qr8uCyh7AmYFTqGroesm","name":"17 SEVENTEEN","coverArt":"ar-62qr8uCyh7AmYFTqGroesm_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTYycXI4dUN5aDdBbVlGVHFHcm9lc21fMCIsImlzcyI6Ik5EIn0._1Bkk7O3dNWpfsAQX6wzBlU-ujZJ64qjTWYcFE3NL08?size=600","musicBrainzId":"","sortName":"17 seventeen","roles":["artist","composer","producer","maincredit","albumartist"]},{"id":"5yZjZwoBql4MIx8H6x5F1o","name":"2kizz","coverArt":"ar-5yZjZwoBql4MIx8H6x5F1o_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTV5Wmpad29CcWw0TUl4OEg2eDVGMW9fMCIsImlzcyI6Ik5EIn0.s1ht6fWan7sk1Du4OwcjcebiXY6b2nZTMivkxkztetg?size=600","musicBrainzId":"","sortName":"2kizz","roles":["artist","composer","producer","maincredit","albumartist"]},{"id":"6s95evG6nwNSd0mnfjsbCz","name":"3TERNITY","coverArt":"ar-6s95evG6nwNSd0mnfjsbCz_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTZzOTVldkc2bndOU2QwbW5manNiQ3pfMCIsImlzcyI6Ik5EIn0.UADf3hxiJ8KiK7e4rGwCJUXqt7eFO-HUCDfIp3Effic?size=600","musicBrainzId":"","sortName":"3ternity","roles":["albumartist","artist","composer","maincredit"]},{"id":"0xMsw5NccH5Syedj08drV9","name":"44neverluv","coverArt":"ar-0xMsw5NccH5Syedj08drV9_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTB4TXN3NU5jY0g1U3llZGowOGRyVjlfMCIsImlzcyI6Ik5EIn0.LGQeZwJf07Oe9DJfiaszOBDEM-arVcUdO-9yUOAHljU?size=600","musicBrainzId":"","sortName":"44neverluv","roles":["producer","maincredit","albumartist","artist","composer"]},{"id":"5JkMqHkP41lzJw8CoWNGxr","name":"¥$","coverArt":"ar-5JkMqHkP41lzJw8CoWNGxr_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTVKa01xSGtQNDFsekp3OENvV05HeHJfMCIsImlzcyI6Ik5EIn0.iLopIwEPwM2n5ypNyV6ObKo8QJNYjeuBrjmoOJ7Mm0w?size=600","musicBrainzId":"","sortName":"¥$","roles":["maincredit","albumartist","artist"]},{"id":"1ldo7ND3Xt2U5y9RwcNIOG","name":"Валентин Дядька","coverArt":"ar-1ldo7ND3Xt2U5y9RwcNIOG_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTFsZG83TkQzWHQyVTV5OVJ3Y05JT0dfMCIsImlzcyI6Ik5EIn0.ZXX2NdBfQAGp5bkzBVy8cKD0Miak6RHFnlIFxukOxp4?size=600","musicBrainzId":"","sortName":"валентин дядька","roles":["maincredit","albumartist","artist"]},{"id":"1fbZVBFq1QzaxaS0oZKW7H","name":"Ваня Дмитриенко, Мальбэк \u0026 Сюзанна","coverArt":"ar-1fbZVBFq1QzaxaS0oZKW7H_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTFmYlpWQkZxMVF6YXhhUzBvWktXN0hfMCIsImlzcyI6Ik5EIn0.8FO6yTekbFrbL5XCcYof-UduhFm83ZThhtb51IlXITs?size=600","musicBrainzId":"","sortName":"ваня дмитриенко, мальбэк \u0026 сюзанна","roles":["performer","maincredit","albumartist","artist"]},{"id":"7v0nmjPcxsKDPInhOlBnNs","name":"дмитрий уткин","coverArt":"ar-7v0nmjPcxsKDPInhOlBnNs_0","albumCount":4,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTd2MG5talBjeHNLRFBJbmhPbEJuTnNfMCIsImlzcyI6Ik5EIn0.vBFBp9z5yF0ig26XbnfpuYoF-dJykhqYF_qrJeu4ykU?size=600","musicBrainzId":"","sortName":"дмитрий уткин","roles":["maincredit","albumartist","artist"]},{"id":"479vtVFOv5EM4T2i8TNURF","name":"Мак Сима Мгла","coverArt":"ar-479vtVFOv5EM4T2i8TNURF_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTQ3OXZ0VkZPdjVFTTRUMmk4VE5VUkZfMCIsImlzcyI6Ik5EIn0.7AyEsIve3EjPD9bHrLIHq0qLX_VRsmVzLJppAeX5gZA?size=600","musicBrainzId":"","sortName":"мак сима мгла","roles":["maincredit","albumartist","artist"]},{"id":"6YYriejN9jz0ThCkxVXJNC","name":"Мак Сима Мгла,zavet","coverArt":"ar-6YYriejN9jz0ThCkxVXJNC_0","albumCount":1,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTZZWXJpZWpOOWp6MFRoQ2t4VlhKTkNfMCIsImlzcyI6Ik5EIn0.GaiBNetZk_P8JKuNjd7vx1ttjUnX5Ai_V4EsYc_FE5U?size=600","musicBrainzId":"","sortName":"мак сима мгла,zavet","roles":["albumartist","maincredit"]},{"id":"6a7GXnrHWsEoqwFqmzr9Cg","name":"НТР","coverArt":"ar-6a7GXnrHWsEoqwFqmzr9Cg_0","albumCount":2,"artistImageUrl":"https://music.hhhhh.dev/share/img/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFyLTZhN0dYbnJIV3NFb3F3RnFtenI5Q2dfMCIsImlzcyI6Ik5EIn0.YUd8IwxmeE4axWLcvHGB5O-PvVxO2BX-O1h06Qvs85Y?size=600","musicBrainzId":"","sortName":"нтр","roles":["maincredit","albumartist","artist"]},{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня","coverArt":"ar-22qLine truncated
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"openSubsonicExtensions":[{"name":"transcodeOffset","versions":[1]},{"name":"formPost","versions":[1]},{"name":"songLyrics","versions":[1,2]},{"name":"indexBasedQueue","versions":[1]},{"name":"transcoding","versions":[1]},{"name":"playbackReport","versions":[1]}]}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"genres":{"genre":[{"value":"Rap","songCount":92,"albumCount":19},{"value":"Hip-Hop","songCount":68,"albumCount":21},{"value":"rusrap","songCount":58,"albumCount":11},{"value":"Electronic","songCount":55,"albumCount":5},{"value":"Hip Hop","songCount":40,"albumCount":14},{"value":"Rock","songCount":28,"albumCount":3},{"value":"Ambient","songCount":28,"albumCount":2},{"value":"alternative","songCount":14,"albumCount":4},{"value":"Alternative Rap","songCount":14,"albumCount":1},{"value":"Drum'n'bass","songCount":14,"albumCount":1},{"value":"Jungle","songCount":14,"albumCount":1},{"value":"New Age","songCount":14,"albumCount":1},{"value":"Pop","songCount":12,"albumCount":2},{"value":"Rap \u0026 Hip-Hop","songCount":5,"albumCount":5},{"value":"Gospel","songCount":2,"albumCount":1},{"value":"zavet","songCount":2,"albumCount":1},{"value":"ruspop","songCount":1,"albumCount":1},{"value":"rusrock","songCount":1,"albumCount":1}]}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"lyricsList":{}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"playlist":{"id":"SxiV9RaUyXY7MN8nYsWZxG","name":"Cassette Wrapped 2026","songCount":29,"duration":3846,"owner":"h","created":"2026-09-19T12:53:49.208808878Z","changed":"2026-09-19T15:01:55.279867381Z","coverArt":"pl-SxiV9RaUyXY7MN8nYsWZxG_6aaea3e3","readonly":false,"entry":[{"id":"b5M4eR9OT8HoteegaIdKdz","parent":"4iyL0qIZurpodlZ1X5grpm","isDir":false,"title":"Спущусь потрогать траву","album":"Спущусь потрогать траву","artist":"New Sylveon","coverArt":"mf-b5M4eR9OT8HoteegaIdKdz_6a872219","size":3509700,"contentType":"audio/mpeg","suffix":"mp3","duration":211,"bitRate":128,"path":"New Sylveon/Спущусь потрогать траву/Спущусь потрогать траву.mp3","playCount":1,"created":"2026-08-20T05:31:27.797462023Z","albumId":"4iyL0qIZurpodlZ1X5grpm","artistId":"1O3tH1OWBPZmewMIvjBKT0","type":"music","played":"2026-08-20T15:16:08.960894876Z","bpm":0,"comment":"","sortName":"спущусь потрогать траву","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayArtist":"New Sylveon","albumArtists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayAlbumArtist":"New Sylveon","contributors":[],"displayComposer":"","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"OLousaLERPvAp1yZzpY5LE","parent":"4769HW4vf62DAOCqgJdLIG","isDir":false,"title":"Дети давят на глаза","album":"Moth Avenue","artist":"New Sylveon","coverArt":"mf-OLousaLERPvAp1yZzpY5LE_6a876810","size":3289378,"contentType":"audio/mpeg","suffix":"mp3","duration":196,"bitRate":128,"path":"New Sylveon/Moth Avenue/Дети давят на глаза.mp3","playCount":1,"created":"2026-08-20T05:05:03.351733151Z","albumId":"4769HW4vf62DAOCqgJdLIG","artistId":"1O3tH1OWBPZmewMIvjBKT0","type":"music","played":"2026-08-20T14:30:10.684555127Z","bpm":0,"comment":"","sortName":"дети давят на глаза","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayArtist":"New Sylveon","albumArtists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayAlbumArtist":"New Sylveon","contributors":[{"role":"producer","artist":{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}},{"role":"composer","artist":{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}}],"displayComposer":"New Sylveon","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"Lj2pIAQdo5ZYmY5OyBMaGE","parent":"4769HW4vf62DAOCqgJdLIG","isDir":false,"title":"Nosebleed Url","album":"Moth Avenue","artist":"New Sylveon","coverArt":"mf-Lj2pIAQdo5ZYmY5OyBMaGE_6a87680a","size":2969307,"contentType":"audio/mpeg","suffix":"mp3","duration":176,"bitRate":128,"path":"New Sylveon/Moth Avenue/Nosebleed Url.mp3","playCount":1,"created":"2026-08-20T05:04:46.979793295Z","albumId":"4769HW4vf62DAOCqgJdLIG","artistId":"1O3tH1OWBPZmewMIvjBKT0","type":"music","played":"2026-08-20T15:30:38.225764505Z","bpm":0,"comment":"","sortName":"nosebleed url","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayArtist":"New Sylveon","albumArtists":[{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}],"displayAlbumArtist":"New Sylveon","contributors":[{"role":"composer","artist":{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}},{"role":"producer","artist":{"id":"1O3tH1OWBPZmewMIvjBKT0","name":"New Sylveon"}}],"displayComposer":"New Sylveon","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"8zIlzzTOxw8v36PtkElVXu","parent":"3J6nXEPOmELLzfiddV7cke","isDir":false,"title":"навсегда","album":"навсегда","artist":"Рай не сегодня","coverArt":"mf-8zIlzzTOxw8v36PtkElVXu_6a87221b","size":2923885,"contentType":"audio/mpeg","suffix":"mp3","duration":161,"bitRate":128,"path":"Рай не сегодня/навсегда/навсегда.mp3","playCount":1,"created":"2026-08-20T05:21:22.317778934Z","albumId":"3J6nXEPOmELLzfiddV7cke","artistId":"22qO7WPYmgKqChrt72BFOf","type":"music","played":"2026-08-20T14:27:11.12139062Z","bpm":0,"comment":"","sortName":"навсегда","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня"}],"displayArtist":"Рай не сегодня","albumArtists":[{"id":"22qO7WPYmgKqChrt72BFOf","name":"Рай не сегодня"}],"displayAlbumArtist":"Рай не сегоLine truncated
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"playlists":{"playlist":[{"id":"SxiV9RaUyXY7MN8nYsWZxG","name":"Cassette Wrapped 2026","songCount":29,"duration":3846,"owner":"h","created":"2026-09-19T12:53:49.208808878Z","changed":"2026-09-19T15:01:55.279867381Z","coverArt":"pl-SxiV9RaUyXY7MN8nYsWZxG_6aaea3e3","readonly":false},{"id":"BmM44LH1EjGvaTe1y8mzlp","name":"Cassette · Energetic","songCount":75,"duration":17271,"owner":"h","created":"2026-08-20T13:29:17.025013179Z","changed":"2026-09-19T12:53:49.574024953Z","coverArt":"pl-BmM44LH1EjGvaTe1y8mzlp_6aae85dd","readonly":false},{"id":"9sUF073MB7Uswaq3H6oJbr","name":"Cassette · Focus","songCount":28,"duration":6259,"owner":"h","created":"2026-09-19T12:53:50.02321498Z","changed":"2026-09-19T12:53:50.21339625Z","coverArt":"pl-9sUF073MB7Uswaq3H6oJbr_6aae85de","readonly":false},{"id":"JHUzaQgnhiqKcXcNyZGpnu","name":"Cassette · Night","songCount":28,"duration":6259,"owner":"h","created":"2026-09-19T12:53:49.298670292Z","changed":"2026-09-19T12:53:49.482594642Z","coverArt":"pl-JHUzaQgnhiqKcXcNyZGpnu_6aae85dd","readonly":false},{"id":"WSIEQCLmb0gkcMXXFQxIs7","name":"Cassette · Workout","songCount":40,"duration":5585,"owner":"h","created":"2026-08-20T13:29:17.307805723Z","changed":"2026-09-19T12:53:49.675658939Z","coverArt":"pl-WSIEQCLmb0gkcMXXFQxIs7_6aae85dd","readonly":false}]}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"internetRadioStations":{"internetRadioStation":[{"id":"7kDdJP1A1NGRzKcHKhXJ24","name":"Złote Przeboje Poznań","streamUrl":"http://radiostream.pl/tuba8926-1.mp3","homePageUrl":"http://zloteprzeboje.tuba.pl/","coverArt":""}]}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"searchResult3":{"song":[{"id":"gldIYgc7CP9Qk2i6C5jYsl","parent":"18jXBF5TwumfzTGqRIpuQ8","isDir":false,"title":"Cобаки кухня ужин","album":"Атана","artist":"zavet","track":5,"year":2019,"genre":"alternative","coverArt":"mf-gldIYgc7CP9Qk2i6C5jYsl_6a8763a6","size":3484925,"contentType":"audio/mpeg","suffix":"mp3","duration":135,"bitRate":192,"path":"zavet/Атана/05 - Cобаки кухня ужин.mp3","playCount":1,"created":"2026-08-20T04:14:23.106069116Z","albumId":"18jXBF5TwumfzTGqRIpuQ8","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","type":"music","played":"2026-09-20T19:19:01.864342992Z","bpm":0,"comment":"","sortName":"cобаки кухня ужин","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[{"name":"alternative"}],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","albumArtists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayAlbumArtist":"zavet","contributors":[],"displayComposer":"","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"fhUcevtPvPy323PUCn6TBe","parent":"1IWVqdo1l5XO1b2Nd3EfB5","isDir":false,"title":"Изящно","album":"Kaidan","artist":"zavet/Мак Сима Мгла","track":9,"year":2019,"genre":"rusrap","coverArt":"mf-fhUcevtPvPy323PUCn6TBe_6a8763a6","size":3998293,"contentType":"audio/mpeg","suffix":"mp3","duration":164,"bitRate":192,"path":"zavet/Kaidan/09 - Изящно.mp3","created":"2026-08-20T04:14:23.126789624Z","albumId":"1IWVqdo1l5XO1b2Nd3EfB5","artistId":"2AvTMErm7wgGUiIBT452v1","type":"music","bpm":0,"comment":"","sortName":"изящно","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[{"name":"rusrap"}],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"2AvTMErm7wgGUiIBT452v1","name":"zavet/Мак Сима Мгла"}],"displayArtist":"zavet/Мак Сима Мгла","albumArtists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayAlbumArtist":"zavet","contributors":[],"displayComposer":"","explicitStatus":"","groupings":[],"works":[],"movements":[]},{"id":"asPoMDbYDlvcjPHAidJkhr","parent":"1IWVqdo1l5XO1b2Nd3EfB5","isDir":false,"title":"бедная лошадь (вывод)","album":"Kaidan","artist":"zavet","track":14,"year":2019,"genre":"rusrap","coverArt":"mf-asPoMDbYDlvcjPHAidJkhr_6a8763a6","size":1522037,"contentType":"audio/mpeg","suffix":"mp3","duration":62,"bitRate":192,"path":"zavet/Kaidan/14 - бедная лошадь (вывод).mp3","created":"2026-08-20T04:14:23.132295342Z","albumId":"1IWVqdo1l5XO1b2Nd3EfB5","artistId":"2lzWXQ0Wd7RzG6RmnWUaTy","type":"music","bpm":0,"comment":"","sortName":"бедная лошадь (вывод)","mediaType":"song","musicBrainzId":"","isrc":[],"genres":[{"name":"rusrap"}],"replayGain":{},"channelCount":2,"samplingRate":44100,"bitDepth":0,"moods":[],"artists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayArtist":"zavet","albumArtists":[{"id":"2lzWXQ0Wd7RzG6RmnWUaTy","name":"zavet"}],"displayAlbumArtist":"zavet","contributors":[],"displayComposer":"","explicitStatus":"","groupings":[],"works":[],"movements":[]}]}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"0.63.2 (be10f89c)","openSubsonic":true,"starred2":{}}}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import Domain
|
||||||
|
import Foundation
|
||||||
|
@testable import SourceSubsonic
|
||||||
|
import Synchronization
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
private let source = SubsonicSource.fixture()
|
||||||
|
|
||||||
|
@Test func requestsCarryTokenAuth() async throws {
|
||||||
|
let request = try await source.stream(for: EntityID(source: source.id, remote: "abc"))
|
||||||
|
let (method, params) = FixtureTransport.method(of: URLRequest(url: request.resource.url))
|
||||||
|
#expect(method == "stream")
|
||||||
|
#expect(params["id"] == "abc")
|
||||||
|
#expect(params["format"] == "raw")
|
||||||
|
#expect(params["u"] == "h")
|
||||||
|
#expect(params["t"]?.count == 32)
|
||||||
|
#expect(params["s"]?.count == 12)
|
||||||
|
#expect(params["v"] == "1.16.1")
|
||||||
|
#expect(params["c"] == "Amphlify")
|
||||||
|
#expect(params["f"] == "json")
|
||||||
|
#expect(params["p"] == nil)
|
||||||
|
#expect(request.resource.headers.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func saltIsFreshPerRequest() {
|
||||||
|
let first = FixtureTransport.method(of: URLRequest(url: source.client.url("ping"))).1
|
||||||
|
let second = FixtureTransport.method(of: URLRequest(url: source.client.url("ping"))).1
|
||||||
|
#expect(first["s"] != second["s"])
|
||||||
|
#expect(first["t"] != second["t"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func errorEnvelopeThrows() {
|
||||||
|
let json = Data(
|
||||||
|
#"{"subsonic-response":{"status":"failed","version":"1.16.1","error":{"code":40,"message":"Wrong username or password"}}}"#
|
||||||
|
.utf8
|
||||||
|
)
|
||||||
|
#expect(throws: SubsonicError.server(code: 40, message: "Wrong username or password")) {
|
||||||
|
try SubsonicClient.decode(json, as: EmptyPayload.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func httpErrorThrows() async throws {
|
||||||
|
let client = try SubsonicClient(baseURL: #require(URL(string: "https://x.test")), username: "h", password: "p") { request in
|
||||||
|
(Data(), HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)!)
|
||||||
|
}
|
||||||
|
await #expect(throws: SubsonicError.http(401)) {
|
||||||
|
try await client.call("ping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func verifyPings() async throws {
|
||||||
|
try await source.verify()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func fullCrawlEmitsWholeLibrary() async throws {
|
||||||
|
let changes = try await SubsonicSource.fixture().collect()
|
||||||
|
var artists = 0, albums = 0, tracks = 0, playlists = 0, stations = 0, annotations = 0, removals = 0
|
||||||
|
var entries: [PlaylistEntry] = []
|
||||||
|
for change in changes {
|
||||||
|
switch change {
|
||||||
|
case .upsert(.artist): artists += 1
|
||||||
|
case .upsert(.album): albums += 1
|
||||||
|
case .upsert(.track): tracks += 1
|
||||||
|
case let .upsert(.playlist(_, list)):
|
||||||
|
playlists += 1
|
||||||
|
entries = list
|
||||||
|
case .upsert(.station): stations += 1
|
||||||
|
case .annotate: annotations += 1
|
||||||
|
case .remove: removals += 1
|
||||||
|
case .checkpoint: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#expect(artists == 83)
|
||||||
|
#expect(tracks == 3)
|
||||||
|
#expect(albums == 207)
|
||||||
|
#expect(playlists == 5)
|
||||||
|
#expect(entries.count == 29)
|
||||||
|
#expect(entries.first?.id == "SxiV9RaUyXY7MN8nYsWZxG:0")
|
||||||
|
#expect(stations == 1)
|
||||||
|
#expect(annotations == 1)
|
||||||
|
#expect(removals == 0)
|
||||||
|
guard case .checkpoint = changes.last else {
|
||||||
|
Issue.record("no checkpoint")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func crawlOrderAndAnnotation() async throws {
|
||||||
|
let changes = try await SubsonicSource.fixture().collect()
|
||||||
|
guard case .upsert(.artist) = changes[0], case .upsert(.track) = changes[83], case .upsert(.album) = changes[86] else {
|
||||||
|
Issue.record("unexpected order")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let annotation = changes.compactMap { change -> Annotation? in
|
||||||
|
if case let .annotate(id, annotation) = change, id.remote == "gldIYgc7CP9Qk2i6C5jYsl" {
|
||||||
|
return annotation
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}.first
|
||||||
|
#expect(annotation?.playCount == 1)
|
||||||
|
#expect(annotation?.favoritedAt == nil)
|
||||||
|
#expect(annotation?.lastPlayedAt?.timeIntervalSince1970.rounded() == 1_789_931_942)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func secondCrawlHasNoRemovals() async throws {
|
||||||
|
let source = SubsonicSource.fixture()
|
||||||
|
_ = try await source.collect()
|
||||||
|
let second = try await source.collect()
|
||||||
|
#expect(second.filter {
|
||||||
|
if case .remove = $0 {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func removalAfterAlbumDisappears() async throws {
|
||||||
|
let base = FixtureTransport()
|
||||||
|
let dropped = FixtureTransport(droppedAlbum: "0OpO6hd2TejwnaHREMESA6")
|
||||||
|
let flag = Mutex(false)
|
||||||
|
let client = try SubsonicClient(baseURL: #require(URL(string: "https://x.test")), username: "h", password: "p") { request in
|
||||||
|
try await (flag.withLock { $0 } ? dropped : base)(request)
|
||||||
|
}
|
||||||
|
let source = SubsonicSource(id: SourceID(), name: "Test", client: client)
|
||||||
|
_ = try await source.collect()
|
||||||
|
flag.withLock { $0 = true }
|
||||||
|
let removals = try await source.collect().compactMap { change -> (EntityKind, EntityID)? in
|
||||||
|
if case let .remove(kind, id) = change {
|
||||||
|
return (kind, id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
#expect(removals.count == 1)
|
||||||
|
#expect(removals.first?.0 == .album)
|
||||||
|
#expect(removals.first?.1.remote == "0OpO6hd2TejwnaHREMESA6")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func flacFormatMapping() throws {
|
||||||
|
let payload = try SubsonicClient.decode(FixtureTransport.fixture("album"), as: AlbumPayload.self)
|
||||||
|
let song = try #require(payload.album.song?.first)
|
||||||
|
let format = try #require(AudioFormat(song: song))
|
||||||
|
#expect(format.codec == .flac)
|
||||||
|
#expect(format.isLossless)
|
||||||
|
#expect(format.sampleRate == 44100)
|
||||||
|
#expect(format.bitDepth == 16)
|
||||||
|
#expect(format.channels == 2)
|
||||||
|
#expect(format.bitrateKbps == 173)
|
||||||
|
#expect(format.isBetter(than: AudioFormat(codec: .mp3, bitrateKbps: 320, sampleRate: 48000)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func lyricsParsing() throws {
|
||||||
|
let json = Data(#"""
|
||||||
|
{"subsonic-response":{"status":"ok","version":"1.16.1","lyricsList":{"structuredLyrics":[
|
||||||
|
{"lang":"xxx","synced":false,"displayArtist":"A","displayTitle":"T","line":[{"value":"plain"}]},
|
||||||
|
{"lang":"xxx","synced":true,"displayArtist":"A","displayTitle":"T","offset":100,
|
||||||
|
"line":[{"start":1000,"value":"one"},{"start":2500,"value":"two"}]}
|
||||||
|
]}}}
|
||||||
|
"""#.utf8)
|
||||||
|
let payload = try SubsonicClient.decode(json, as: LyricsPayload.self)
|
||||||
|
let lyrics = try #require(SubsonicMapper(source: SourceID()).lyrics(payload.lyricsList.structuredLyrics ?? []))
|
||||||
|
#expect(lyrics.isSynced)
|
||||||
|
#expect(lyrics.lines.map(\.text) == ["one", "two"])
|
||||||
|
#expect(lyrics.lines.map(\.time) == [1.1, 2.6])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyLyricsIsNil() async throws {
|
||||||
|
#expect(try await source.lyrics(for: EntityID(source: source.id, remote: "x")) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func artworkRequest() async throws {
|
||||||
|
let request = try await source.artwork(EntityID(source: source.id, remote: "al-1"), pixelSize: 300)
|
||||||
|
let (method, params) = FixtureTransport.method(of: URLRequest(url: request.url))
|
||||||
|
#expect(method == "getCoverArt")
|
||||||
|
#expect(params["id"] == "al-1")
|
||||||
|
#expect(params["size"] == "300")
|
||||||
|
#expect(params["t"] != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func streamRemembersFormat() async throws {
|
||||||
|
let source = SubsonicSource.fixture()
|
||||||
|
_ = try await source.collect()
|
||||||
|
let request = try await source.stream(for: EntityID(source: source.id, remote: "gldIYgc7CP9Qk2i6C5jYsl"))
|
||||||
|
#expect(request.format?.codec == .mp3)
|
||||||
|
#expect(request.format?.bitrateKbps == 192)
|
||||||
|
#expect(request.resource.url.absoluteString.contains("format=raw"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func nanosecondDatesParse() {
|
||||||
|
let date = SubsonicDate.parse("2026-09-21T13:33:43.30709994Z")
|
||||||
|
#expect(date.map { abs($0.timeIntervalSince1970 - 1_789_997_623.307) < 0.001 } == true)
|
||||||
|
#expect(SubsonicDate.parse("2026-08-20T13:57:31.661Z") != nil)
|
||||||
|
#expect(SubsonicDate.parse("2026-08-20T13:57:31Z") != nil)
|
||||||
|
#expect(SubsonicDate.parse("garbage") == nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
import SourceFake
|
||||||
|
import Store
|
||||||
|
import Sync
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@Test func fakeSourceSyncsIntoLibrary() async throws {
|
||||||
|
let library = MemoryLibrary()
|
||||||
|
let source = FakeSource()
|
||||||
|
try await SyncEngine(library: library).sync(source)
|
||||||
|
let albums = await library.albums(sortedBy: .title)
|
||||||
|
#expect(albums.count == 35)
|
||||||
|
#expect(await library.cursor(for: source.id) != nil)
|
||||||
|
let first = try #require(albums.first)
|
||||||
|
#expect(await library.tracks(inAlbum: first.id).count == first.trackCount)
|
||||||
|
#expect(await !library.search("harbor").artists.isEmpty)
|
||||||
|
try await SyncEngine(library: library).sync(source)
|
||||||
|
#expect(await library.albums(sortedBy: .title).count == 35)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func fakeArtworkRenders() async throws {
|
||||||
|
let source = FakeSource()
|
||||||
|
let library = MemoryLibrary()
|
||||||
|
try await SyncEngine(library: library).sync(source)
|
||||||
|
let artwork = try #require(await library.albums(sortedBy: .title).first?.artwork)
|
||||||
|
let request = try await source.artwork(artwork, pixelSize: 300)
|
||||||
|
#expect(try Data(contentsOf: request.url).count > 1000)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Amphlify
|
||||||
|
|
||||||
|
A native music player for iPhone (macOS and watchOS to follow) for your own OpenSubsonic server, such as Navidrome. Part of Soundoz.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Needs Xcode 27+, iOS 26+.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp Config/Signing.example.xcconfig Config/Signing.xcconfig # set your team and bundle prefix
|
||||||
|
open Amphlify.xcodeproj
|
||||||
|
```
|
||||||
|
|
||||||
|
`make fmt`, `make check`, `make test`, `make run`.
|
||||||
Reference in new issue
Block a user