diff --git a/.github/workflows/pull_request_checks.yaml b/.github/workflows/pull_request_checks.yaml index 76a736bb7..e6b5ffd83 100644 --- a/.github/workflows/pull_request_checks.yaml +++ b/.github/workflows/pull_request_checks.yaml @@ -53,3 +53,28 @@ jobs: - name: Run PWA tests run: npm run test + + apple-test: + name: Apple Test + runs-on: macos-15 + defaults: + run: + working-directory: apps/apple + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate Xcode project + run: xcodegen generate + + - name: Run Apple tests + run: > + xcodebuild test + -project Cicada.xcodeproj + -scheme Cicada + -destination 'platform=macOS' + CODE_SIGNING_ALLOWED=NO diff --git a/Makefile b/Makefile index d4b732e47..f79c8bc34 100644 --- a/Makefile +++ b/Makefile @@ -15,16 +15,25 @@ define stage_ffmpeg_bundle node scripts/prepare_ffmpeg_bundle.mjs --target $(1) --version "$(FFMPEG_VERSION)" $(if $($(2)),--archive "$($(2))") $(if $($(3)),--sha256 "$($(3))") endef -.PHONY: pwa release docker clean ffmpeg-bundles ffmpeg-bundle-darwin-arm64 ffmpeg-bundle-windows-amd64 ffmpeg-bundle-windows-arm64 ffmpeg-bundle-linux-amd64 ffmpeg-bundle-linux-arm64 +.PHONY: pwa storybook release docker clean ffmpeg-bundles ffmpeg-bundle-darwin-arm64 ffmpeg-bundle-windows-amd64 ffmpeg-bundle-windows-arm64 ffmpeg-bundle-linux-amd64 ffmpeg-bundle-linux-arm64 + +## 安装前端依赖 (供 pwa 与 storybook 构建复用, 依赖 lockfile 变更时才重装) +apps/pwa/node_modules/.package-lock.json: apps/pwa/package-lock.json + npm ci --prefix apps/pwa ## 构建 PWA 并嵌入 CLI -pwa: +pwa: apps/pwa/node_modules/.package-lock.json node scripts/build_version.mjs validate "$(VERSION)" - npm ci --prefix apps/pwa CICADA_VERSION=$(VERSION) npm run build --prefix apps/pwa rm -rf $(CLI_DIR)/pwa/dist cp -R apps/pwa/dist $(CLI_DIR)/pwa/dist +## 构建 Storybook 并嵌入 CLI (托管于 /storybook) +storybook: apps/pwa/node_modules/.package-lock.json + npm run build-storybook --prefix apps/pwa + rm -rf $(CLI_DIR)/storybook/static + cp -R apps/pwa/storybook-static $(CLI_DIR)/storybook/static + ffmpeg-bundle-darwin-arm64: $(call stage_ffmpeg_bundle,darwin-arm64,FFMPEG_ARCHIVE_DARWIN_ARM64,FFMPEG_SHA256_DARWIN_ARM64) @@ -48,7 +57,7 @@ ffmpeg-bundles: \ ffmpeg-bundle-linux-arm64 ## 全平台构建发布包 (默认目标) -release: pwa ffmpeg-bundles +release: pwa storybook ffmpeg-bundles rm -rf $(BUILD_DIR) mkdir -p $(BUILD_DIR) mkdir -p $(BUILD_DIR)/darwin-arm64 @@ -74,7 +83,7 @@ release: pwa ffmpeg-bundles $(BUILD_DIR)/linux-arm64 ## 构建 Linux 多架构二进制 (供 Docker buildx 使用, 不压缩) -docker: pwa ffmpeg-bundle-linux-amd64 ffmpeg-bundle-linux-arm64 +docker: pwa storybook ffmpeg-bundle-linux-amd64 ffmpeg-bundle-linux-arm64 rm -rf $(BUILD_DIR) mkdir -p $(BUILD_DIR)/linux-amd64 $(call build_cli,linux,amd64,$(BUILD_DIR)/linux-amd64/cicada) @@ -84,4 +93,4 @@ docker: pwa ffmpeg-bundle-linux-amd64 ffmpeg-bundle-linux-arm64 ## 清理构建产物 clean: - rm -rf $(BUILD_DIR) apps/cli/pwa/dist apps/pwa/dist apps/cli/internal/ffmpeg/generated apps/cli/internal/ffmpeg/zz_bundle_*.go + rm -rf $(BUILD_DIR) apps/cli/pwa/dist apps/pwa/dist apps/cli/storybook/static apps/pwa/storybook-static apps/cli/internal/ffmpeg/generated apps/cli/internal/ffmpeg/zz_bundle_*.go diff --git a/apps/apple/Cicada.xcodeproj/project.pbxproj b/apps/apple/Cicada.xcodeproj/project.pbxproj index b5207cb76..297ab179b 100644 --- a/apps/apple/Cicada.xcodeproj/project.pbxproj +++ b/apps/apple/Cicada.xcodeproj/project.pbxproj @@ -9,48 +9,82 @@ /* Begin PBXBuildFile section */ 0971B6785F612465DAC7E5BD /* CaptchaImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC82AC368AC0AD4A87C72665 /* CaptchaImageView.swift */; }; 0F984FDDD69B564E0230A4CB /* CicadaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B910A3665B4DDA99878FF71 /* CicadaApp.swift */; }; + 112C703AE04F465FA9762C56 /* PlayerExplorationRadioViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC72DC5FD4E50276A92E4654 /* PlayerExplorationRadioViews.swift */; }; 1CD8C8775EFCD83973DB933B /* AudioPlayerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01204AADF1F4582CCE6A10EA /* AudioPlayerController.swift */; }; 2A6F7DF61B34ACA044BB046D /* APIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA29737AD4D244F48D114F8 /* APIModels.swift */; }; 31B3D04BEAC099A5CD672D60 /* PlayerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6CCF322654991CDF520E710 /* PlayerStore.swift */; }; + 3F50FA295F60CA24919349C1 /* PlayerSearchViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1370A6FCEDFFA172C619609A /* PlayerSearchViews.swift */; }; + 42A5CBE405310DBB5D3B40DF /* ServerSetupStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DBB7D56DD8812C26BA79DD4 /* ServerSetupStoreTests.swift */; }; + 4C9130DBF7664AE42E680D69 /* ServerRecordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFF48282943C43077475A76 /* ServerRecordTests.swift */; }; 522E7C044AD259D040DB6A46 /* OfflineCacheView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23FB3F71F15FCC9E8CB388FE /* OfflineCacheView.swift */; }; 540311FF630F605EB2D029B6 /* AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CBA9673A23B3A3AF4CDB32B /* AppColors.swift */; }; + 66FD613565F71EEBF361F900 /* PlayerMusicbillSupportViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DBD8C10FAF67A419AFF5173 /* PlayerMusicbillSupportViews.swift */; }; + 6C0E5E60E5A3182A246AAF2A /* PlayerAccountViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5EF6368C0FEEE5E53E431 /* PlayerAccountViews.swift */; }; 6D00ED9698D002F1A9D4C814 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C55AE864A81F35D85018195F /* AppSettings.swift */; }; 84F4925E7C08BF0AE904CF85 /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62AB02B4FFD013256547676 /* PlayerView.swift */; }; 8A0CD7264E3F54F3AFE8C6F5 /* CicadaAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50DB83045806185AB692E010 /* CicadaAPIClient.swift */; }; 8BB183D6F68837E6692076B4 /* LoginStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B1F585BB98EF97B6443166 /* LoginStore.swift */; }; + 8D1F23444735F772CBD0299A /* PlayerMusicbillDetailViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38BD4AA96F4EFF200DB3773F /* PlayerMusicbillDetailViews.swift */; }; 8FDB0E06C64B71D69AABD4B5 /* PlatformInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2F0A927788D9B8B22DC854 /* PlatformInfo.swift */; }; 9941AEA5E64BF322A1E85221 /* ServerSetupStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36EF13F40A064872555EB02B /* ServerSetupStore.swift */; }; + 9B503D867B3EF4203CE562B9 /* PlayerLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A0A9F20EAEDA5ADA461D4AE /* PlayerLayout.swift */; }; 9CA0E180D97A85B9E1EDE04D /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7067CA4929E277FCFCE50DDA /* LoginView.swift */; }; + A9ACDE9ADB7C8A824BD89ACC /* PlayerNowPlayingViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1CF43D8AB99EC59094A4E1B /* PlayerNowPlayingViews.swift */; }; + ADC8C0AC91684F87F92E7699 /* PlayerMusicbillNameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 737D4E4B35286C69006237ED /* PlayerMusicbillNameSheet.swift */; }; B099B02F694B9F862BCA197D /* AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = E996EE12D82CF08D777808F8 /* AppVersion.swift */; }; B30F51263A6F4D6D4707BA71 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2250FE910651D41CF043E8E9 /* SettingsView.swift */; }; + B801386A0863B523FD6C5CF7 /* PlayerSidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B71744A7DEAB1117098C109 /* PlayerSidebar.swift */; }; BA878239D0B590108F8B4AA4 /* ServerRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE82971270AEBC4A2CEAF2A /* ServerRecord.swift */; }; C949567AF84958831A724FB9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 11DF74913AF882BC9F3654D6 /* Assets.xcassets */; }; C9EFAA3FE8DE7F4B5E114691 /* OfflineCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B42BC74FFEB76515CE2B3E /* OfflineCacheManager.swift */; }; D45A1192D9D8B8471640DC09 /* ServerMetadataClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51CB5BD527128393183232E7 /* ServerMetadataClient.swift */; }; DA49924C26B41DAB98303DB6 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844DE39A003BD06B5E365B8B /* ContentView.swift */; }; DAB7FB034762D499332DF78A /* ServerSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A15DAAA9D87410AC3E70B9 /* ServerSetupView.swift */; }; + EA50C3199C36F6847CE6574B /* AppNavigationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E159272B8453578EF7343C /* AppNavigationStore.swift */; }; F70A633508CDCE7968069DF8 /* ArtworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1670EB172F3C2EDCDEFC845 /* ArtworkView.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 049DA1426BD0AD56B2B41D73 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 14800BBF7013A048425A1E92 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 124777041E4254679B8F1E36; + remoteInfo = Cicada; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 01204AADF1F4582CCE6A10EA /* AudioPlayerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerController.swift; sourceTree = ""; }; 03B1F585BB98EF97B6443166 /* LoginStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginStore.swift; sourceTree = ""; }; 11DF74913AF882BC9F3654D6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 1370A6FCEDFFA172C619609A /* PlayerSearchViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSearchViews.swift; sourceTree = ""; }; + 1B71744A7DEAB1117098C109 /* PlayerSidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSidebar.swift; sourceTree = ""; }; 2250FE910651D41CF043E8E9 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 23FB3F71F15FCC9E8CB388FE /* OfflineCacheView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineCacheView.swift; sourceTree = ""; }; 2B910A3665B4DDA99878FF71 /* CicadaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CicadaApp.swift; sourceTree = ""; }; + 2DBD8C10FAF67A419AFF5173 /* PlayerMusicbillSupportViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerMusicbillSupportViews.swift; sourceTree = ""; }; 36EF13F40A064872555EB02B /* ServerSetupStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSetupStore.swift; sourceTree = ""; }; 37B42BC74FFEB76515CE2B3E /* OfflineCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineCacheManager.swift; sourceTree = ""; }; + 38BD4AA96F4EFF200DB3773F /* PlayerMusicbillDetailViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerMusicbillDetailViews.swift; sourceTree = ""; }; 50DB83045806185AB692E010 /* CicadaAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CicadaAPIClient.swift; sourceTree = ""; }; + 51B5EF6368C0FEEE5E53E431 /* PlayerAccountViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerAccountViews.swift; sourceTree = ""; }; 51CB5BD527128393183232E7 /* ServerMetadataClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMetadataClient.swift; sourceTree = ""; }; + 6DBB7D56DD8812C26BA79DD4 /* ServerSetupStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSetupStoreTests.swift; sourceTree = ""; }; 7067CA4929E277FCFCE50DDA /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; + 737D4E4B35286C69006237ED /* PlayerMusicbillNameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerMusicbillNameSheet.swift; sourceTree = ""; }; 7CBA9673A23B3A3AF4CDB32B /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = ""; }; 844DE39A003BD06B5E365B8B /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 8A0A9F20EAEDA5ADA461D4AE /* PlayerLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerLayout.swift; sourceTree = ""; }; 9C663FB13EEF5FDD127BC458 /* Cicada.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Cicada.app; sourceTree = BUILT_PRODUCTS_DIR; }; A62AB02B4FFD013256547676 /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = ""; }; AB2F0A927788D9B8B22DC854 /* PlatformInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformInfo.swift; sourceTree = ""; }; AC69C6574E7CD3012188A930 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + B05C0C97D99C862F41003F01 /* CicadaTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = CicadaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + BBFF48282943C43077475A76 /* ServerRecordTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerRecordTests.swift; sourceTree = ""; }; + BC72DC5FD4E50276A92E4654 /* PlayerExplorationRadioViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerExplorationRadioViews.swift; sourceTree = ""; }; BCE82971270AEBC4A2CEAF2A /* ServerRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerRecord.swift; sourceTree = ""; }; + C0E159272B8453578EF7343C /* AppNavigationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationStore.swift; sourceTree = ""; }; C55AE864A81F35D85018195F /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; C9A15DAAA9D87410AC3E70B9 /* ServerSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSetupView.swift; sourceTree = ""; }; DFA29737AD4D244F48D114F8 /* APIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIModels.swift; sourceTree = ""; }; @@ -58,6 +92,7 @@ E6CCF322654991CDF520E710 /* PlayerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStore.swift; sourceTree = ""; }; E996EE12D82CF08D777808F8 /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = ""; }; EC82AC368AC0AD4A87C72665 /* CaptchaImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptchaImageView.swift; sourceTree = ""; }; + F1CF43D8AB99EC59094A4E1B /* PlayerNowPlayingViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNowPlayingViews.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -77,6 +112,7 @@ isa = PBXGroup; children = ( 9C663FB13EEF5FDD127BC458 /* Cicada.app */, + B05C0C97D99C862F41003F01 /* CicadaTests.xctest */, ); name = Products; sourceTree = ""; @@ -118,6 +154,7 @@ 5EC1E076E2C5CDA3244BDD54 /* App */ = { isa = PBXGroup; children = ( + C0E159272B8453578EF7343C /* AppNavigationStore.swift */, 2B910A3665B4DDA99878FF71 /* CicadaApp.swift */, ); path = App; @@ -149,10 +186,20 @@ path = Settings; sourceTree = ""; }; + 9DC9071A7E07EB417BD3AE6A /* CicadaTests */ = { + isa = PBXGroup; + children = ( + BBFF48282943C43077475A76 /* ServerRecordTests.swift */, + 6DBB7D56DD8812C26BA79DD4 /* ServerSetupStoreTests.swift */, + ); + path = CicadaTests; + sourceTree = ""; + }; BDE7D3869486DF739938B1BE = { isa = PBXGroup; children = ( 57FBA0E7E939B674E1E61726 /* Cicada */, + 9DC9071A7E07EB417BD3AE6A /* CicadaTests */, 15ECD16330D6D168583775E6 /* Products */, ); sourceTree = ""; @@ -160,6 +207,15 @@ CE2BE27675CD84C5A4E682FD /* Player */ = { isa = PBXGroup; children = ( + 51B5EF6368C0FEEE5E53E431 /* PlayerAccountViews.swift */, + BC72DC5FD4E50276A92E4654 /* PlayerExplorationRadioViews.swift */, + 8A0A9F20EAEDA5ADA461D4AE /* PlayerLayout.swift */, + 38BD4AA96F4EFF200DB3773F /* PlayerMusicbillDetailViews.swift */, + 737D4E4B35286C69006237ED /* PlayerMusicbillNameSheet.swift */, + 2DBD8C10FAF67A419AFF5173 /* PlayerMusicbillSupportViews.swift */, + F1CF43D8AB99EC59094A4E1B /* PlayerNowPlayingViews.swift */, + 1370A6FCEDFFA172C619609A /* PlayerSearchViews.swift */, + 1B71744A7DEAB1117098C109 /* PlayerSidebar.swift */, E6CCF322654991CDF520E710 /* PlayerStore.swift */, A62AB02B4FFD013256547676 /* PlayerView.swift */, ); @@ -218,6 +274,24 @@ productReference = 9C663FB13EEF5FDD127BC458 /* Cicada.app */; productType = "com.apple.product-type.application"; }; + 2873F1B452FA6D23CE8135B7 /* CicadaTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 83E46CC99AA4EE157550B64F /* Build configuration list for PBXNativeTarget "CicadaTests" */; + buildPhases = ( + DA53C21704492983CAD76DD8 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 2A620D00EC99121792068072 /* PBXTargetDependency */, + ); + name = CicadaTests; + packageProductDependencies = ( + ); + productName = CicadaTests; + productReference = B05C0C97D99C862F41003F01 /* CicadaTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -247,6 +321,7 @@ projectRoot = ""; targets = ( 124777041E4254679B8F1E36 /* Cicada */, + 2873F1B452FA6D23CE8135B7 /* CicadaTests */, ); }; /* End PBXProject section */ @@ -292,6 +367,7 @@ files = ( 2A6F7DF61B34ACA044BB046D /* APIModels.swift in Sources */, 540311FF630F605EB2D029B6 /* AppColors.swift in Sources */, + EA50C3199C36F6847CE6574B /* AppNavigationStore.swift in Sources */, 6D00ED9698D002F1A9D4C814 /* AppSettings.swift in Sources */, B099B02F694B9F862BCA197D /* AppVersion.swift in Sources */, F70A633508CDCE7968069DF8 /* ArtworkView.swift in Sources */, @@ -305,6 +381,15 @@ C9EFAA3FE8DE7F4B5E114691 /* OfflineCacheManager.swift in Sources */, 522E7C044AD259D040DB6A46 /* OfflineCacheView.swift in Sources */, 8FDB0E06C64B71D69AABD4B5 /* PlatformInfo.swift in Sources */, + 6C0E5E60E5A3182A246AAF2A /* PlayerAccountViews.swift in Sources */, + 112C703AE04F465FA9762C56 /* PlayerExplorationRadioViews.swift in Sources */, + 9B503D867B3EF4203CE562B9 /* PlayerLayout.swift in Sources */, + 8D1F23444735F772CBD0299A /* PlayerMusicbillDetailViews.swift in Sources */, + ADC8C0AC91684F87F92E7699 /* PlayerMusicbillNameSheet.swift in Sources */, + 66FD613565F71EEBF361F900 /* PlayerMusicbillSupportViews.swift in Sources */, + A9ACDE9ADB7C8A824BD89ACC /* PlayerNowPlayingViews.swift in Sources */, + 3F50FA295F60CA24919349C1 /* PlayerSearchViews.swift in Sources */, + B801386A0863B523FD6C5CF7 /* PlayerSidebar.swift in Sources */, 31B3D04BEAC099A5CD672D60 /* PlayerStore.swift in Sources */, 84F4925E7C08BF0AE904CF85 /* PlayerView.swift in Sources */, D45A1192D9D8B8471640DC09 /* ServerMetadataClient.swift in Sources */, @@ -315,9 +400,50 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DA53C21704492983CAD76DD8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4C9130DBF7664AE42E680D69 /* ServerRecordTests.swift in Sources */, + 42A5CBE405310DBB5D3B40DF /* ServerSetupStoreTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 2A620D00EC99121792068072 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 124777041E4254679B8F1E36 /* Cicada */; + targetProxy = 049DA1426BD0AD56B2B41D73 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 31293CA0EF89A1FC9785CACB /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + "BUNDLE_LOADER[sdk=macosx*]" = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.github.manyone.cicada.apple.tests; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Cicada.app/Cicada"; + "TEST_HOST[sdk=macosx*]" = "$(BUILT_PRODUCTS_DIR)/Cicada.app/Contents/MacOS/Cicada"; + }; + name = Release; + }; 346532BEFD397AE80A237650 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -344,11 +470,36 @@ }; name = Release; }; + 4201408F920657BEFD60F799 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + "BUNDLE_LOADER[sdk=macosx*]" = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.github.manyone.cicada.apple.tests; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Cicada.app/Cicada"; + "TEST_HOST[sdk=macosx*]" = "$(BUILT_PRODUCTS_DIR)/Cicada.app/Contents/MacOS/Cicada"; + }; + name = Debug; + }; 4896A955FB9B7A1D8D348FFB /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGNING_ALLOWED = NO; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; ENABLE_PREVIEWS = YES; @@ -516,6 +667,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 83E46CC99AA4EE157550B64F /* Build configuration list for PBXNativeTarget "CicadaTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4201408F920657BEFD60F799 /* Debug */, + 31293CA0EF89A1FC9785CACB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ }; rootObject = 14800BBF7013A048425A1E92 /* Project object */; diff --git a/apps/apple/Cicada.xcodeproj/xcshareddata/xcschemes/Cicada.xcscheme b/apps/apple/Cicada.xcodeproj/xcshareddata/xcschemes/Cicada.xcscheme index 6bc4b5872..72ed21b20 100644 --- a/apps/apple/Cicada.xcodeproj/xcshareddata/xcschemes/Cicada.xcscheme +++ b/apps/apple/Cicada.xcodeproj/xcshareddata/xcschemes/Cicada.xcscheme @@ -21,6 +21,20 @@ ReferencedContainer = "container:Cicada.xcodeproj"> + + + + + + + + + + 32 { + nickname = String(value.prefix(32)) + } + } + .onSubmit { + guard canSaveNickname else { return } + Task { + await saveNickname() + } + } + + LabeledContent("Characters", value: "\(trimmedNickname.count)/32") + .font(.footnote) + .foregroundStyle(trimmedNickname.count > 32 ? Color.red : Color.secondary) + } + + Section("Account") { + LabeledContent("Username", value: user.username) + LabeledContent("Joined", value: formatCicadaTimestamp(user.joinTimestamp, date: .abbreviated, time: .omitted)) + } + + Section { + Button { + Task { + await saveNickname() + } + } label: { + if playerStore.isUpdatingProfile { + ProgressView() + } else { + Label("Save Nickname", systemImage: "checkmark") + } + } + .disabled(!canSaveNickname) + + Button { + isShowingPasswordSheet = true + } label: { + Label("Change Password", systemImage: "key") + } + .disabled(playerStore.isChangingPassword) + + Button { + isShowingSessions = true + } label: { + Label("Authorized Devices", systemImage: "desktopcomputer.and.macbook") + } + + Button { + userForDetail = UserDetailTarget(id: user.id) + } label: { + Label("Public Profile", systemImage: "person.text.rectangle") + } + } + } + } else { + ContentUnavailableView( + "No User Selected", + systemImage: "person.crop.circle.badge.exclamationmark", + description: Text("Sign in before editing profile.") + ) + } + } + .navigationTitle("Profile") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + if showsDoneButton { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + } + + ToolbarItem(placement: .primaryAction) { + Button { + Task { + await refreshProfile() + } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(playerStore.isUpdatingProfile) + .help("Refresh Profile") + } + } + } + + private func refreshProfile() async { + if let profile = await playerStore.refreshCurrentProfile() { + serverStore.updateSelectedUser(profile: profile) + nickname = profile.nickname + } + } + + private func saveNickname() async { + guard canSaveNickname else { return } + if let profile = await playerStore.updateNickname(nickname) { + serverStore.updateSelectedUser(profile: profile) + nickname = profile.nickname + } + } +} + +struct ChangePasswordSheet: View { + @ObservedObject var playerStore: PlayerStore + let twoFAEnabled: Bool + + @Environment(\.dismiss) private var dismiss + @State private var credential = "" + @State private var newPassword = "" + @State private var confirmation = "" + + private var canSubmit: Bool { + !credential.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + newPassword.count >= 6 && + newPassword.count <= 32 && + newPassword == confirmation && + !playerStore.isChangingPassword + } + + var body: some View { + NavigationStack { + Form { + Section { + SecureField( + twoFAEnabled ? "Current Password or 2FA Token" : "Current Password", + text: $credential + ) + SecureField("New Password", text: $newPassword) + .onChange(of: newPassword) { _, value in + if value.count > 32 { + newPassword = String(value.prefix(32)) + } + } + SecureField("Confirm Password", text: $confirmation) + .onChange(of: confirmation) { _, value in + if value.count > 32 { + confirmation = String(value.prefix(32)) + } + } + } footer: { + Text("Password must be 6-32 characters.") + } + + if !confirmation.isEmpty && newPassword != confirmation { + Section { + Label("Passwords do not match.", systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } + } + } + .navigationTitle("Change Password") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + .disabled(playerStore.isChangingPassword) + } + + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + await submit() + } + } label: { + if playerStore.isChangingPassword { + ProgressView() + } else { + Text("Save") + } + } + .disabled(!canSubmit) + } + } + } + } + + private func submit() async { + let didChange = await playerStore.changePassword( + credential: credential, + newPassword: newPassword + ) + if didChange { + dismiss() + } + } +} + +struct AuthorizedDevicesView: View { + @ObservedObject var playerStore: PlayerStore + + @Environment(\.dismiss) private var dismiss + @State private var sessionForRename: AuthSession? + @State private var sessionForRevocation: AuthSession? + + var body: some View { + NavigationStack { + content + .navigationTitle("Authorized Devices") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + + ToolbarItem(placement: .primaryAction) { + Button { + Task { + await playerStore.loadSessions(force: true) + } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(playerStore.isLoadingSessions) + .help("Refresh Devices") + } + } + } + .sheet(item: $sessionForRename) { session in + RenameDeviceSheet( + playerStore: playerStore, + session: session + ) + } + .confirmationDialog( + "Revoke Device?", + isPresented: Binding( + get: { sessionForRevocation != nil }, + set: { isPresented in + if !isPresented { + sessionForRevocation = nil + } + } + ), + titleVisibility: .visible + ) { + if let sessionForRevocation { + Button("Revoke", role: .destructive) { + Task { + let didRevoke = await playerStore.revokeSession(id: sessionForRevocation.id) + if didRevoke { + self.sessionForRevocation = nil + } + } + } + } + Button("Cancel", role: .cancel) { + sessionForRevocation = nil + } + } message: { + if let sessionForRevocation { + Text("Revoke \(displayDeviceName(sessionForRevocation)) from this account.") + } + } + .task { + await playerStore.loadSessions() + } + } + + @ViewBuilder + private var content: some View { + if playerStore.isLoadingSessions && playerStore.sessions.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if !playerStore.hasLoadedSessions { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if playerStore.sessions.isEmpty { + ContentUnavailableView( + "No Authorized Devices", + systemImage: "desktopcomputer.and.macbook", + description: Text("Signed-in devices will appear here.") + ) + } else { + List { + Section { + ForEach(playerStore.sessions) { session in + AuthSessionRow( + session: session, + isUpdating: playerStore.updatingSessionIDs.contains(session.id) + ) { + sessionForRename = session + } onRevoke: { + sessionForRevocation = session + } + } + } + } + .refreshable { + await playerStore.loadSessions(force: true) + } + .overlay { + if playerStore.isLoadingSessions { + ProgressView() + .padding(16) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + } +} + +struct AuthSessionRow: View { + let session: AuthSession + let isUpdating: Bool + let onRename: () -> Void + let onRevoke: () -> Void + + var body: some View { + HStack(spacing: 12) { + Image(systemName: session.current ? "checkmark.circle.fill" : "desktopcomputer") + .font(.title2) + .foregroundStyle(session.current ? Color.accentColor : Color.secondary) + .frame(width: 34, height: 34) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(displayDeviceName(session)) + .font(.headline) + .lineLimit(1) + if session.current { + Text("Current") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.accentColor.opacity(0.14)) + .clipShape(Capsule()) + } + } + + Text("Last seen \(formatCicadaTimestamp(session.lastSeenTimestamp, date: .abbreviated, time: .shortened))") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + + Text("Expires \(formatCicadaTimestamp(session.inactiveExpireTimestamp, date: .abbreviated, time: .shortened))") + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + + Spacer() + + Menu { + Button { + onRename() + } label: { + Label("Rename", systemImage: "pencil") + } + .disabled(isUpdating) + + if !session.current { + Button(role: .destructive) { + onRevoke() + } label: { + Label("Revoke", systemImage: "trash") + } + .disabled(isUpdating) + } + } label: { + if isUpdating { + ProgressView() + } else { + Image(systemName: "ellipsis.circle") + } + } + .disabled(isUpdating) + } + .padding(.vertical, 4) + } +} + +struct RenameDeviceSheet: View { + @ObservedObject var playerStore: PlayerStore + let session: AuthSession + + @Environment(\.dismiss) private var dismiss + @State private var deviceName: String + + init(playerStore: PlayerStore, session: AuthSession) { + self.playerStore = playerStore + self.session = session + _deviceName = State(initialValue: displayDeviceName(session)) + } + + private var trimmedDeviceName: String { + deviceName.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var isSaving: Bool { + playerStore.updatingSessionIDs.contains(session.id) + } + + private var canSubmit: Bool { + !trimmedDeviceName.isEmpty && + trimmedDeviceName.count <= 64 && + !isSaving + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Device Name", text: $deviceName) + .onChange(of: deviceName) { _, value in + if value.count > 64 { + deviceName = String(value.prefix(64)) + } + } + .onSubmit { + guard canSubmit else { return } + Task { + await submit() + } + } + + LabeledContent("Characters", value: "\(trimmedDeviceName.count)/64") + .font(.footnote) + .foregroundStyle(trimmedDeviceName.count > 64 ? Color.red : Color.secondary) + } + } + .navigationTitle("Rename Device") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + .disabled(isSaving) + } + + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + await submit() + } + } label: { + if isSaving { + ProgressView() + } else { + Text("Save") + } + } + .disabled(!canSubmit) + } + } + } + } + + private func submit() async { + let didRename = await playerStore.renameSession( + id: session.id, + deviceName: trimmedDeviceName + ) + if didRename { + dismiss() + } + } +} + +struct UserProfileView: View { + let userID: UserDetail.ID + @ObservedObject var playerStore: PlayerStore + + @Environment(\.dismiss) private var dismiss + @State private var publicMusicbillForDetail: PublicMusicbillSearchItem? + + private var detail: UserDetail? { + playerStore.userDetails[userID] + } + + var body: some View { + NavigationStack { + Group { + if let detail { + userContent(detail) + } else if playerStore.loadingUserIDs.contains(userID) { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView( + "User Not Loaded", + systemImage: "person.crop.circle.badge.exclamationmark", + description: Text("Pull to refresh or try again.") + ) + } + } + .navigationTitle(detail?.nickname ?? "User") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + } + } + .sheet(item: $publicMusicbillForDetail) { musicbill in + PublicMusicbillDetailView( + musicbill: musicbill, + playerStore: playerStore + ) + } + .task(id: userID) { + await playerStore.loadUser(id: userID) + } + } + + private func userContent(_ detail: UserDetail) -> some View { + List { + Section { + HStack(spacing: 14) { + ArtworkView( + urlString: detail.avatar, + systemImage: "person.crop.square", + size: 76 + ) + + VStack(alignment: .leading, spacing: 5) { + Text(detail.nickname) + .font(.headline) + .lineLimit(1) + Text("@\(detail.username)") + .foregroundStyle(.secondary) + .lineLimit(1) + Text("Joined \(formatCicadaTimestamp(detail.joinTimestamp, date: .abbreviated, time: .omitted))") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + Section("Public Musicbills") { + if detail.musicbillList.isEmpty { + ContentUnavailableView( + "No Public Musicbills", + systemImage: "music.note.list", + description: Text("This user has not published musicbills.") + ) + } else { + ForEach(detail.musicbillList) { musicbill in + UserPublicMusicbillRow(musicbill: musicbill) { + publicMusicbillForDetail = musicbill.searchItem(user: detail.musicbillUser) + } + } + } + } + } + .refreshable { + await playerStore.loadUser(id: detail.id, force: true) + } + } +} + +struct UserPublicMusicbillRow: View { + let musicbill: UserPublicMusicbill + let onOpen: () -> Void + + var body: some View { + Button(action: onOpen) { + HStack(spacing: 12) { + ArtworkView( + urlString: musicbill.cover, + placeholderURLString: musicbill.coverThumbnail, + systemImage: "music.note.list", + size: 50 + ) + + VStack(alignment: .leading, spacing: 4) { + Text(musicbill.name) + .font(.headline) + .lineLimit(1) + Text("\(musicbill.musicCount) songs") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} diff --git a/apps/apple/Cicada/Features/Player/PlayerExplorationRadioViews.swift b/apps/apple/Cicada/Features/Player/PlayerExplorationRadioViews.swift new file mode 100644 index 000000000..6644b79c8 --- /dev/null +++ b/apps/apple/Cicada/Features/Player/PlayerExplorationRadioViews.swift @@ -0,0 +1,265 @@ +import Foundation +import SwiftUI + +// MARK: - Exploration + +struct ExplorationView: View { + @ObservedObject var playerStore: PlayerStore + + @Environment(\.dismiss) private var dismiss + let showsDoneButton: Bool + let embedsInNavigationStack: Bool + + init( + playerStore: PlayerStore, + showsDoneButton: Bool = true, + embedsInNavigationStack: Bool = true + ) { + self.playerStore = playerStore + self.showsDoneButton = showsDoneButton + self.embedsInNavigationStack = embedsInNavigationStack + } + + var body: some View { + Group { + if embedsInNavigationStack { + NavigationStack { + pageContent + } + } else { + pageContent + } + } + } + + private var pageContent: some View { + ExplorationContentView(playerStore: playerStore) + .navigationTitle("Explore") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + if showsDoneButton { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + } + + ToolbarItem(placement: .primaryAction) { + Button { + Task { + await playerStore.loadExploration(force: true) + } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(playerStore.isLoadingExploration) + .help("Refresh") + } + } + } +} + +struct ExplorationContentView: View { + @ObservedObject var playerStore: PlayerStore + + @State private var artistForDetail: ArtistSearchItem? + @State private var publicMusicbillForDetail: PublicMusicbillSearchItem? + + var body: some View { + content + .task { + await playerStore.loadExploration() + } + .sheet(item: $artistForDetail) { artist in + ArtistDetailView(artist: artist, playerStore: playerStore) + } + .sheet(item: $publicMusicbillForDetail) { musicbill in + PublicMusicbillDetailView(musicbill: musicbill, playerStore: playerStore) + } + } + + @ViewBuilder + private var content: some View { + if playerStore.isLoadingExploration && playerStore.exploration == nil { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let exploration = playerStore.exploration, !exploration.isEmpty { + List { + musicSection("For You", items: exploration.musicList) + artistSection("Artists", items: exploration.artistList) + musicbillSection("Public Musicbills", items: exploration.publicMusicbillList) + musicSection("Recently Added", items: exploration.recentMusicList) + artistSection("Recent Artists", items: exploration.recentArtistList) + musicbillSection("Recent Musicbills", items: exploration.recentPublicMusicbillList) + } + .overlay { + if playerStore.isLoadingExploration { + ProgressView() + .padding(16) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } else if playerStore.exploration != nil { + ContentUnavailableView( + "Nothing to Explore", + systemImage: "sparkles", + description: Text("Check back later for new picks.") + ) + } else { + ContentUnavailableView( + "Explore", + systemImage: "sparkles", + description: Text("Discover music, artists, and public musicbills.") + ) + } + } + + @ViewBuilder + private func musicSection(_ title: String, items: [ExplorationMusicItem]) -> some View { + if !items.isEmpty { + Section(title) { + ForEach(items) { music in + ExplorationMusicRow( + music: music, + isCurrent: playerStore.audioPlayer.currentMusic?.id == music.id, + isPlaying: playerStore.audioPlayer.currentMusic?.id == music.id && playerStore.audioPlayer.isPlaying + ) { + Task { + await playerStore.playMusic(id: music.id) + } + } + } + } + } + } + + @ViewBuilder + private func artistSection(_ title: String, items: [ExplorationArtistItem]) -> some View { + if !items.isEmpty { + Section(title) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 14) { + ForEach(items) { artist in + ExplorationArtistCard(artist: artist) { + artistForDetail = artist.asSearchItem() + } + } + } + .padding(.vertical, 4) + } + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + } + } + + @ViewBuilder + private func musicbillSection(_ title: String, items: [ExplorationPublicMusicbillItem]) -> some View { + if !items.isEmpty { + Section(title) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 14) { + ForEach(items) { musicbill in + ExplorationMusicbillCard(musicbill: musicbill) { + publicMusicbillForDetail = musicbill.asSearchItem() + } + } + } + .padding(.vertical, 4) + } + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + } + } +} + +struct ExplorationMusicRow: View { + let music: ExplorationMusicItem + let isCurrent: Bool + let isPlaying: Bool + let onPlay: () -> Void + + var body: some View { + Button(action: onPlay) { + HStack(spacing: 12) { + ArtworkView( + urlString: music.coverThumbnail?.isEmpty == false ? music.coverThumbnail : music.cover, + systemImage: "music.note", + size: 44 + ) + + VStack(alignment: .leading, spacing: 3) { + Text(music.name) + .lineLimit(1) + Text(music.performerLine) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + if isCurrent { + Image(systemName: isPlaying ? "speaker.wave.2.fill" : "pause.circle") + .foregroundStyle(.tint) + } + } + } + .buttonStyle(.plain) + } +} + +struct ExplorationArtistCard: View { + let artist: ExplorationArtistItem + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + VStack(spacing: 8) { + ArtworkView( + urlString: artist.photos.first?.thumbnail?.isEmpty == false ? artist.photos.first?.thumbnail : artist.avatar, + systemImage: "music.mic", + size: 96 + ) + .clipShape(Circle()) + + Text(artist.name) + .font(.footnote) + .lineLimit(1) + .frame(width: 96) + } + } + .buttonStyle(.plain) + } +} + +struct ExplorationMusicbillCard: View { + let musicbill: ExplorationPublicMusicbillItem + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + VStack(alignment: .leading, spacing: 8) { + ArtworkView( + urlString: musicbill.cover, + placeholderURLString: musicbill.coverThumbnail, + systemImage: "music.note.list", + size: 120 + ) + + Text(musicbill.name) + .font(.footnote) + .lineLimit(1) + Text(musicbill.user.nickname) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(width: 120, alignment: .leading) + } + .buttonStyle(.plain) + } +} diff --git a/apps/apple/Cicada/Features/Player/PlayerLayout.swift b/apps/apple/Cicada/Features/Player/PlayerLayout.swift new file mode 100644 index 000000000..72218229d --- /dev/null +++ b/apps/apple/Cicada/Features/Player/PlayerLayout.swift @@ -0,0 +1,128 @@ +import Foundation +import SwiftUI + +#if os(macOS) +enum MacPlayerLayoutMetrics { + static let collapseWidth: CGFloat = 760 + static let sidebarWidth: CGFloat = 280 + static let overlayMaximumWidth: CGFloat = 320 + static let overlayWidthRatio: CGFloat = 0.82 + static let edgePadding: CGFloat = 12 + static let contentSpacing: CGFloat = 0 + static let sidebarCornerRadius: CGFloat = 18 +} + +struct MacPlayerLayout: View { + @Binding var isSidebarPresented: Bool + private let sidebar: Sidebar + private let detail: Detail + + init( + isSidebarPresented: Binding, + @ViewBuilder sidebar: () -> Sidebar, + @ViewBuilder detail: () -> Detail + ) { + _isSidebarPresented = isSidebarPresented + self.sidebar = sidebar() + self.detail = detail() + } + + var body: some View { + GeometryReader { proxy in + let isCollapsed = proxy.size.width < MacPlayerLayoutMetrics.collapseWidth + // Keep the floating panel 12pt from the window edge, not 12pt below the macOS titlebar safe area. + let sidebarTopPadding = MacPlayerLayoutMetrics.edgePadding - proxy.safeAreaInsets.top + let overlayWidth = min( + MacPlayerLayoutMetrics.overlayMaximumWidth, + max(MacPlayerLayoutMetrics.sidebarWidth, proxy.size.width * MacPlayerLayoutMetrics.overlayWidthRatio) + ) + + ZStack(alignment: .leading) { + Color.cicadaSidebarVoidBackground + .ignoresSafeArea() + + HStack(spacing: MacPlayerLayoutMetrics.contentSpacing) { + if !isCollapsed { + floatingSidebar + .frame(width: MacPlayerLayoutMetrics.sidebarWidth) + .padding(.leading, MacPlayerLayoutMetrics.edgePadding) + .padding(.top, sidebarTopPadding) + .padding(.bottom, MacPlayerLayoutMetrics.edgePadding) + } + + NavigationStack { + detail + .toolbar { + if isCollapsed { + ToolbarItem(placement: .navigation) { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + isSidebarPresented.toggle() + } + } label: { + Image(systemName: "sidebar.left") + } + .help("Show Sidebar") + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.cicadaBackground) + } + + if isCollapsed && isSidebarPresented { + Color.black.opacity(0.18) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.easeInOut(duration: 0.18)) { + isSidebarPresented = false + } + } + + HStack(spacing: 0) { + floatingSidebar + .frame(width: overlayWidth) + .padding(.leading, MacPlayerLayoutMetrics.edgePadding) + .padding(.top, sidebarTopPadding) + .padding(.bottom, MacPlayerLayoutMetrics.edgePadding) + + Spacer(minLength: 0) + } + .transition(.move(edge: .leading)) + } + } + .onChange(of: isCollapsed) { _, isCollapsed in + if !isCollapsed { + isSidebarPresented = false + } + } + } + } + + private var floatingSidebar: some View { + sidebar + .background( + RoundedRectangle( + cornerRadius: MacPlayerLayoutMetrics.sidebarCornerRadius, + style: .continuous + ) + .fill(Color.cicadaSidebarBackground) + ) + .overlay { + RoundedRectangle( + cornerRadius: MacPlayerLayoutMetrics.sidebarCornerRadius, + style: .continuous + ) + .stroke(Color.primary.opacity(0.06), lineWidth: 1) + } + .clipShape( + RoundedRectangle( + cornerRadius: MacPlayerLayoutMetrics.sidebarCornerRadius, + style: .continuous + ) + ) + .shadow(color: Color.black.opacity(0.13), radius: 18, x: 0, y: 10) + } +} +#endif diff --git a/apps/apple/Cicada/Features/Player/PlayerMusicbillDetailViews.swift b/apps/apple/Cicada/Features/Player/PlayerMusicbillDetailViews.swift new file mode 100644 index 000000000..97faab421 --- /dev/null +++ b/apps/apple/Cicada/Features/Player/PlayerMusicbillDetailViews.swift @@ -0,0 +1,649 @@ +import Foundation +import SwiftUI + +struct MusicbillSharedUsersView: View { + @ObservedObject var playerStore: PlayerStore + let musicbillID: MusicbillDetail.ID + + @Environment(\.dismiss) private var dismiss + @State private var isShowingInviteSheet = false + @State private var userForRemoval: MusicbillUser? + @State private var userForDetail: UserDetailTarget? + @State private var isConfirmingLeave = false + + private var detail: MusicbillDetail? { + playerStore.musicbillDetails[musicbillID] + } + + private var isUpdating: Bool { + playerStore.updatingSharedUserMusicbillIDs.contains(musicbillID) + } + + var body: some View { + NavigationStack { + content + .navigationTitle("Shared Users") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + .disabled(isUpdating) + } + + ToolbarItem(placement: .primaryAction) { + Button { + isShowingInviteSheet = true + } label: { + Image(systemName: "person.badge.plus") + } + .disabled(detail == nil || isUpdating) + .help("Invite User") + } + } + } + .sheet(isPresented: $isShowingInviteSheet) { + InviteSharedUserSheet( + playerStore: playerStore, + musicbillID: musicbillID + ) + } + .sheet(item: $userForDetail) { target in + UserProfileView( + userID: target.id, + playerStore: playerStore + ) + } + .confirmationDialog( + "Remove Shared User?", + isPresented: Binding( + get: { userForRemoval != nil }, + set: { isPresented in + if !isPresented { + userForRemoval = nil + } + } + ), + titleVisibility: .visible + ) { + if let userForRemoval { + Button("Remove", role: .destructive) { + Task { + let didRemove = await playerStore.removeSharedUser( + userID: userForRemoval.id, + from: musicbillID + ) + if didRemove { + self.userForRemoval = nil + } + } + } + } + Button("Cancel", role: .cancel) { + userForRemoval = nil + } + } message: { + if let userForRemoval { + Text("Remove \(userForRemoval.nickname) from this shared musicbill.") + } + } + .confirmationDialog( + "Leave Shared Musicbill?", + isPresented: $isConfirmingLeave, + titleVisibility: .visible + ) { + Button("Leave", role: .destructive) { + Task { + let didLeave = await playerStore.leaveSharedMusicbill(id: musicbillID) + if didLeave { + dismiss() + } + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This musicbill will be removed from your sidebar.") + } + .task(id: musicbillID) { + await playerStore.loadMusicbill(id: musicbillID) + } + } + + @ViewBuilder + private var content: some View { + if let detail { + userList(detail) + } else if playerStore.loadingMusicbillIDs.contains(musicbillID) { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView( + "Shared Users Not Loaded", + systemImage: "person.2", + description: Text("Pull to refresh or try again.") + ) + } + } + + private func userList(_ detail: MusicbillDetail) -> some View { + let isOwner = playerStore.isMusicbillOwner(detail) + + return List { + Section("Owner") { + MusicbillSharedUserRow( + user: detail.owner, + role: .owner, + canRemove: false, + isUpdating: isUpdating + ) { + userForDetail = UserDetailTarget(id: detail.owner.id) + } onRemove: {} + } + + Section("Shared Users") { + if detail.sharedUserList.isEmpty { + ContentUnavailableView( + "No Shared Users", + systemImage: "person.2.slash", + description: Text("Invite a user to share this musicbill.") + ) + } else { + ForEach(detail.sharedUserList) { sharedUser in + MusicbillSharedUserRow( + user: sharedUser, + role: sharedUser.accepted == false ? .pending : .accepted, + canRemove: isOwner, + isUpdating: isUpdating + ) { + userForDetail = UserDetailTarget(id: sharedUser.id) + } onRemove: { + userForRemoval = sharedUser + } + } + } + } + + if !isOwner { + Section { + Button(role: .destructive) { + isConfirmingLeave = true + } label: { + Label("Leave Shared Musicbill", systemImage: "rectangle.portrait.and.arrow.right") + } + .disabled(isUpdating) + } + } + } + .refreshable { + await playerStore.loadMusicbill(id: detail.id, force: true) + } + .overlay { + if isUpdating { + ProgressView() + .padding(16) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } +} + +enum MusicbillSharedUserRole: Equatable { + case owner + case accepted + case pending + + var title: String { + switch self { + case .owner: + return "Owner" + case .accepted: + return "Accepted" + case .pending: + return "Pending" + } + } + + var systemImage: String { + switch self { + case .owner: + return "crown" + case .accepted: + return "checkmark.circle" + case .pending: + return "clock" + } + } +} + +struct MusicbillSharedUserRow: View { + let user: MusicbillUser + let role: MusicbillSharedUserRole + let canRemove: Bool + let isUpdating: Bool + let onOpen: () -> Void + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 12) { + Button(action: onOpen) { + HStack(spacing: 12) { + ArtworkView( + urlString: user.avatar, + systemImage: "person.crop.square", + size: 44 + ) + + VStack(alignment: .leading, spacing: 4) { + Text(user.nickname) + .lineLimit(1) + + Label(role.title, systemImage: role.systemImage) + .font(.footnote) + .foregroundStyle(role == .pending ? Color.orange : Color.secondary) + .lineLimit(1) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Spacer() + + if canRemove { + Button(role: .destructive, action: onRemove) { + Image(systemName: "trash") + } + .disabled(isUpdating) + .help("Remove User") + } + } + .padding(.vertical, 4) + } +} + +struct InviteSharedUserSheet: View { + @ObservedObject var playerStore: PlayerStore + let musicbillID: MusicbillDetail.ID + + @Environment(\.dismiss) private var dismiss + @State private var username = "" + + private var trimmedUsername: String { + username.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var isSaving: Bool { + playerStore.updatingSharedUserMusicbillIDs.contains(musicbillID) + } + + private var canSubmit: Bool { + !trimmedUsername.isEmpty && + trimmedUsername.count <= 16 && + !isSaving + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Username", text: $username) + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + .autocorrectionDisabled() + .onChange(of: username) { _, value in + username = String(value.trimmingCharacters(in: .whitespacesAndNewlines).prefix(16)) + } + .onSubmit { + guard canSubmit else { return } + Task { + await submit() + } + } + + LabeledContent("Characters", value: "\(trimmedUsername.count)/16") + .font(.footnote) + .foregroundStyle(trimmedUsername.count > 16 ? Color.red : Color.secondary) + } footer: { + Text("The user will receive a shared musicbill invitation.") + } + } + .navigationTitle("Invite User") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + .disabled(isSaving) + } + + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + await submit() + } + } label: { + if isSaving { + ProgressView() + } else { + Text("Invite") + } + } + .disabled(!canSubmit) + } + } + } + } + + private func submit() async { + let didInvite = await playerStore.inviteSharedUser( + username: trimmedUsername, + to: musicbillID + ) + if didInvite { + dismiss() + } + } +} + +struct MusicbillDetailView: View { + @ObservedObject var playerStore: PlayerStore + let musicbillID: MusicbillDetail.ID + @State private var isShowingRenameSheet = false + @State private var isShowingSharedUsers = false + @State private var isConfirmingPublish = false + @State private var captchaAction: MusicbillCaptchaAction? + @State private var musicForMusicbillSelection: Music? + + private var detail: MusicbillDetail? { + playerStore.musicbillDetails[musicbillID] + } + + var body: some View { + Group { + if let detail { + musicList(detail) + } else if playerStore.loadingMusicbillIDs.contains(musicbillID) { + ProgressView() + } else { + ContentUnavailableView( + "Musicbill Not Loaded", + systemImage: "music.note.list", + description: Text("Pull to refresh or choose another musicbill.") + ) + } + } + .navigationTitle(detail?.name ?? playerStore.summary(for: musicbillID)?.name ?? "Musicbill") + .toolbar { + if let detail { + ToolbarItem(placement: .primaryAction) { + musicbillMenu(detail) + } + } + } + .sheet(isPresented: $isShowingRenameSheet) { + MusicbillNameSheet( + title: "Rename Musicbill", + initialName: detail?.name ?? playerStore.summary(for: musicbillID)?.name ?? "", + submitTitle: "Save", + isSaving: playerStore.isSavingMusicbill + ) { name in + await playerStore.renameMusicbill(id: musicbillID, name: name) + } + } + .sheet(isPresented: $isShowingSharedUsers) { + MusicbillSharedUsersView( + playerStore: playerStore, + musicbillID: musicbillID + ) + } + .sheet(item: $captchaAction) { action in + MusicbillCaptchaActionSheet( + action: action, + playerStore: playerStore + ) + } + .sheet(item: $musicForMusicbillSelection) { music in + AddToMusicbillSheet( + music: music, + playerStore: playerStore + ) + } + .confirmationDialog( + "Make this musicbill public?", + isPresented: $isConfirmingPublish, + titleVisibility: .visible + ) { + Button("Make Public") { + Task { + await playerStore.publishMusicbill(id: musicbillID) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Anyone can view a public musicbill.") + } + .task(id: musicbillID) { + await playerStore.loadMusicbill(id: musicbillID) + } + .refreshable { + await playerStore.loadMusicbill(id: musicbillID, force: true) + } + } + + private func musicbillMenu(_ detail: MusicbillDetail) -> some View { + Menu { + Button { + isShowingRenameSheet = true + } label: { + Label("Rename", systemImage: "pencil") + } + + Button { + isShowingSharedUsers = true + } label: { + Label("Shared Users", systemImage: "person.2") + } + + if !detail.isPublic { + Button { + isConfirmingPublish = true + } label: { + Label("Make Public", systemImage: "globe") + } + } else { + Button(role: .destructive) { + captchaAction = .unpublish(id: detail.id, name: detail.name) + } label: { + Label("Make Private", systemImage: "lock") + } + } + + if playerStore.canDeleteMusicbill(detail) { + Divider() + + Button(role: .destructive) { + captchaAction = .delete(id: detail.id, name: detail.name) + } label: { + Label("Delete", systemImage: "trash") + } + } + } label: { + Image(systemName: "ellipsis.circle") + } + .help("Musicbill Actions") + } + + private func musicList(_ detail: MusicbillDetail) -> some View { + List { + Section { + HStack(spacing: 14) { + ArtworkView( + urlString: detail.cover, + placeholderURLString: detail.coverThumbnail, + systemImage: "music.note.list", + size: 72 + ) + + VStack(alignment: .leading, spacing: 5) { + Text(detail.name) + .font(.headline) + Text(detail.owner.nickname) + .foregroundStyle(.secondary) + Text("\(detail.musicList.count) songs") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + Section("Songs") { + if detail.musicList.isEmpty { + ContentUnavailableView( + "No Songs", + systemImage: "music.note", + description: Text("This musicbill does not contain songs yet.") + ) + } else { + ForEach(detail.musicList) { music in + MusicRow( + music: music, + isCurrent: playerStore.audioPlayer.currentMusic?.id == music.id, + isPlaying: playerStore.audioPlayer.currentMusic?.id == music.id && playerStore.audioPlayer.isPlaying + ) { + playerStore.play(music: music, in: detail) + } onAddToMusicbill: { + musicForMusicbillSelection = music + } onRemoveFromMusicbill: { + Task { + await playerStore.removeMusic(music, from: detail.id) + } + } onSaveOffline: { + playerStore.saveOffline(music) + } + } + } + } + } + } +} + +struct MusicRow: View { + let music: Music + let isCurrent: Bool + let isPlaying: Bool + let lyricSnippet: [LyricSearchSnippetLine] + let lyricKeyword: String + let onPlay: () -> Void + let onAddToMusicbill: () -> Void + var onRemoveFromMusicbill: (() -> Void)? = nil + var onSaveOffline: (() -> Void)? = nil + + init( + music: Music, + isCurrent: Bool, + isPlaying: Bool, + lyricSnippet: [LyricSearchSnippetLine] = [], + lyricKeyword: String = "", + onPlay: @escaping () -> Void, + onAddToMusicbill: @escaping () -> Void, + onRemoveFromMusicbill: (() -> Void)? = nil, + onSaveOffline: (() -> Void)? = nil + ) { + self.music = music + self.isCurrent = isCurrent + self.isPlaying = isPlaying + self.lyricSnippet = lyricSnippet + self.lyricKeyword = lyricKeyword + self.onPlay = onPlay + self.onAddToMusicbill = onAddToMusicbill + self.onRemoveFromMusicbill = onRemoveFromMusicbill + self.onSaveOffline = onSaveOffline + } + + var body: some View { + Button(action: onPlay) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + ArtworkView( + urlString: music.coverThumbnail?.isEmpty == false ? music.coverThumbnail : music.cover, + systemImage: "music.note", + size: 44 + ) + + VStack(alignment: .leading, spacing: 3) { + Text(music.name) + .lineLimit(1) + Text(music.performerLine) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + if isCurrent { + Image(systemName: isPlaying ? "speaker.wave.2.fill" : "pause.circle") + .foregroundStyle(.tint) + } + } + + if !lyricSnippet.isEmpty { + lyricSnippetView + } + } + } + .buttonStyle(.plain) + .contextMenu { + Button(action: onAddToMusicbill) { + Label("Add to Musicbill", systemImage: "text.badge.plus") + } + + if let onSaveOffline { + Button(action: onSaveOffline) { + Label("Save for Offline", systemImage: "arrow.down.circle") + } + } + + if let onRemoveFromMusicbill { + Button(role: .destructive, action: onRemoveFromMusicbill) { + Label("Remove from This Musicbill", systemImage: "minus.circle") + } + } + } + .swipeActions(edge: .leading, allowsFullSwipe: false) { + Button(action: onAddToMusicbill) { + Label("Add", systemImage: "text.badge.plus") + } + .tint(.accentColor) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if let onRemoveFromMusicbill { + Button(role: .destructive, action: onRemoveFromMusicbill) { + Label("Remove", systemImage: "minus.circle") + } + } + } + } + + private var lyricSnippetView: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(lyricSnippet) { line in + Text(line.text) + .font(line.isMatch ? .callout.weight(.semibold) : .footnote) + .foregroundStyle(line.isMatch ? Color.accentColor : Color.secondary) + .lineLimit(2) + } + } + .padding(.leading, 56) + .accessibilityLabel("Matched lyrics") + } +} diff --git a/apps/apple/Cicada/Features/Player/PlayerMusicbillNameSheet.swift b/apps/apple/Cicada/Features/Player/PlayerMusicbillNameSheet.swift new file mode 100644 index 000000000..f2c6762f0 --- /dev/null +++ b/apps/apple/Cicada/Features/Player/PlayerMusicbillNameSheet.swift @@ -0,0 +1,88 @@ +import Foundation +import SwiftUI + +struct MusicbillNameSheet: View { + let title: String + let submitTitle: String + let isSaving: Bool + let onSubmit: (String) async -> Bool + + @Environment(\.dismiss) private var dismiss + @State private var name: String + + init( + title: String, + initialName: String, + submitTitle: String, + isSaving: Bool, + onSubmit: @escaping (String) async -> Bool + ) { + self.title = title + self.submitTitle = submitTitle + self.isSaving = isSaving + self.onSubmit = onSubmit + _name = State(initialValue: initialName) + } + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSubmit: Bool { + !trimmedName.isEmpty && trimmedName.count <= 64 && !isSaving + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Name", text: $name) + .onSubmit { + guard canSubmit else { return } + Task { + await submit() + } + } + + LabeledContent("Characters", value: "\(trimmedName.count)/64") + .font(.footnote) + .foregroundStyle(trimmedName.count > 64 ? Color.red : Color.secondary) + } + } + .navigationTitle(title) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + .disabled(isSaving) + } + + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + await submit() + } + } label: { + if isSaving { + ProgressView() + } else { + Text(submitTitle) + } + } + .disabled(!canSubmit) + } + } + } + } + + private func submit() async { + let didSave = await onSubmit(trimmedName) + if didSave { + dismiss() + } + } +} diff --git a/apps/apple/Cicada/Features/Player/PlayerMusicbillSupportViews.swift b/apps/apple/Cicada/Features/Player/PlayerMusicbillSupportViews.swift new file mode 100644 index 000000000..18965cf38 --- /dev/null +++ b/apps/apple/Cicada/Features/Player/PlayerMusicbillSupportViews.swift @@ -0,0 +1,723 @@ +import Foundation +import SwiftUI + +enum MusicbillCaptchaAction: Identifiable, Equatable { + case unpublish(id: MusicbillDetail.ID, name: String) + case delete(id: MusicbillDetail.ID, name: String) + + var id: String { + switch self { + case .unpublish(let id, _): + return "unpublish-\(id)" + case .delete(let id, _): + return "delete-\(id)" + } + } + + var musicbillID: MusicbillDetail.ID { + switch self { + case .unpublish(let id, _), .delete(let id, _): + return id + } + } + + var title: String { + switch self { + case .unpublish: + return "Make Private" + case .delete: + return "Delete Musicbill" + } + } + + var confirmTitle: String { + switch self { + case .unpublish: + return "Make Private" + case .delete: + return "Delete" + } + } + + var message: String { + switch self { + case .unpublish(_, let name): + return "\"\(name)\" will become private. Existing public collections for this musicbill will be removed." + case .delete(_, let name): + return "\"\(name)\" will be deleted. This cannot be undone." + } + } + + var systemImage: String { + switch self { + case .unpublish: + return "lock" + case .delete: + return "trash" + } + } +} + +struct MusicbillCaptchaActionSheet: View { + let action: MusicbillCaptchaAction + @ObservedObject var playerStore: PlayerStore + + @Environment(\.dismiss) private var dismiss + @State private var captchaValue = "" + + private var trimmedCaptchaValue: String { + captchaValue.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSubmit: Bool { + playerStore.musicbillActionCaptcha != nil && + !trimmedCaptchaValue.isEmpty && + !playerStore.isSavingMusicbill && + !playerStore.isLoadingMusicbillActionCaptcha + } + + var body: some View { + NavigationStack { + Form { + Section { + Label(action.message, systemImage: action.systemImage) + .foregroundStyle(.secondary) + } + + Section("Captcha") { + captchaBlock + + TextField("Captcha", text: $captchaValue) + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + .autocorrectionDisabled() + .onSubmit { + guard canSubmit else { return } + Task { + await submit() + } + } + } + } + .navigationTitle(action.title) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + .disabled(playerStore.isSavingMusicbill) + } + + ToolbarItem(placement: .confirmationAction) { + Button(role: .destructive) { + Task { + await submit() + } + } label: { + if playerStore.isSavingMusicbill { + ProgressView() + } else { + Text(action.confirmTitle) + } + } + .disabled(!canSubmit) + } + } + } + .task(id: action.id) { + captchaValue = "" + await playerStore.loadMusicbillActionCaptcha(force: true) + } + .onDisappear { + if !playerStore.isSavingMusicbill { + playerStore.clearMusicbillActionCaptcha() + } + } + } + + private var captchaBlock: some View { + HStack { + Group { + if let captcha = playerStore.musicbillActionCaptcha { + CaptchaImageView(svg: captcha.svg) + } else if playerStore.isLoadingMusicbillActionCaptcha { + ProgressView() + } else { + Image(systemName: "checkmark.shield") + .foregroundStyle(.secondary) + } + } + .frame(width: 160, height: 54) + .background(Color.cicadaSecondaryBackground) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + + Button { + Task { + captchaValue = "" + await playerStore.loadMusicbillActionCaptcha(force: true) + } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(playerStore.isLoadingMusicbillActionCaptcha || playerStore.isSavingMusicbill) + } + } + + private func submit() async { + let didSave: Bool + switch action { + case .unpublish(let id, _): + didSave = await playerStore.unpublishMusicbill( + id: id, + captchaValue: trimmedCaptchaValue + ) + case .delete(let id, _): + didSave = await playerStore.deleteMusicbill( + id: id, + captchaValue: trimmedCaptchaValue + ) + } + + if didSave { + dismiss() + } else { + captchaValue = "" + await playerStore.loadMusicbillActionCaptcha(force: true) + } + } +} + +struct AddToMusicbillSheet: View { + let music: Music + @ObservedObject var playerStore: PlayerStore + + @Environment(\.dismiss) private var dismiss + @State private var workingMusicbillID: MusicbillSummary.ID? + @State private var isShowingCreateMusicbill = false + + var body: some View { + NavigationStack { + Group { + if playerStore.musicbills.isEmpty { + ContentUnavailableView( + "No Musicbills", + systemImage: "music.note.list", + description: Text("Create a musicbill before adding songs.") + ) + } else { + List { + Section { + ForEach(playerStore.musicbills) { musicbill in + AddToMusicbillRow( + musicbill: musicbill, + containsMusic: playerStore.containsMusic(music.id, in: musicbill.id), + isLoading: playerStore.loadingMusicbillIDs.contains(musicbill.id), + isWorking: workingMusicbillID == musicbill.id + ) { + await toggleMusic(in: musicbill) + } + .task(id: musicbill.id) { + await playerStore.loadMusicbill(id: musicbill.id) + } + } + } footer: { + Text("Tap a musicbill to add or remove this song.") + } + } + } + } + .navigationTitle("Add to Musicbill") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + + ToolbarItem(placement: .primaryAction) { + Button { + isShowingCreateMusicbill = true + } label: { + Image(systemName: "plus") + } + .disabled(playerStore.isSavingMusicbill) + .help("New Musicbill") + } + } + } + .sheet(isPresented: $isShowingCreateMusicbill) { + MusicbillNameSheet( + title: "New Musicbill", + initialName: "", + submitTitle: "Create", + isSaving: playerStore.isSavingMusicbill + ) { name in + await playerStore.createMusicbill(name: name) + } + } + } + + private func toggleMusic(in musicbill: MusicbillSummary) async { + guard workingMusicbillID == nil, !playerStore.isSavingMusicbill else { return } + workingMusicbillID = musicbill.id + defer { workingMusicbillID = nil } + + if playerStore.containsMusic(music.id, in: musicbill.id) == nil { + await playerStore.loadMusicbill(id: musicbill.id, force: true) + } + + if playerStore.containsMusic(music.id, in: musicbill.id) == true { + _ = await playerStore.removeMusic(music, from: musicbill.id) + } else { + _ = await playerStore.addMusic(music, to: musicbill.id) + } + } +} + +struct AddToMusicbillRow: View { + let musicbill: MusicbillSummary + let containsMusic: Bool? + let isLoading: Bool + let isWorking: Bool + let onToggle: () async -> Void + + private var isDisabled: Bool { + isWorking || (isLoading && containsMusic == nil) + } + + var body: some View { + Button { + Task { + await onToggle() + } + } label: { + HStack(spacing: 12) { + statusIcon + + ArtworkView( + urlString: musicbill.cover, + placeholderURLString: musicbill.coverThumbnail, + systemImage: "music.note.list", + size: 42 + ) + + VStack(alignment: .leading, spacing: 3) { + Text(musicbill.name) + .lineLimit(1) + + HStack(spacing: 6) { + Text(musicbill.owner.nickname) + .lineLimit(1) + + if musicbill.isPublic { + Label("Public", systemImage: "globe") + .labelStyle(.titleAndIcon) + .lineLimit(1) + } + } + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isDisabled) + } + + @ViewBuilder + private var statusIcon: some View { + if isWorking { + ProgressView() + .frame(width: 24, height: 24) + } else if containsMusic == true { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.tint) + .font(.title3) + .frame(width: 24, height: 24) + } else if isLoading { + ProgressView() + .frame(width: 24, height: 24) + } else { + Image(systemName: "circle") + .foregroundStyle(.secondary) + .font(.title3) + .frame(width: 24, height: 24) + } + } +} + +struct SharedMusicbillInvitationView: View { + @ObservedObject var playerStore: PlayerStore + let showsDoneButton: Bool + let embedsInNavigationStack: Bool + + @Environment(\.dismiss) private var dismiss + @State private var userForDetail: UserDetailTarget? + + init( + playerStore: PlayerStore, + showsDoneButton: Bool = true, + embedsInNavigationStack: Bool = true + ) { + self.playerStore = playerStore + self.showsDoneButton = showsDoneButton + self.embedsInNavigationStack = embedsInNavigationStack + } + + var body: some View { + Group { + if embedsInNavigationStack { + NavigationStack { + pageContent + } + } else { + pageContent + } + } + .task { + await playerStore.loadSharedMusicbillInvitations(force: true) + } + .sheet(item: $userForDetail) { target in + UserProfileView( + userID: target.id, + playerStore: playerStore + ) + } + } + + private var pageContent: some View { + content + .navigationTitle("Shared Invitations") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + if showsDoneButton { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + } + + ToolbarItem(placement: .primaryAction) { + Button { + Task { + await playerStore.loadSharedMusicbillInvitations(force: true) + } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(playerStore.isLoadingSharedMusicbillInvitations) + .help("Refresh Invitations") + } + } + } + + @ViewBuilder + private var content: some View { + if playerStore.isLoadingSharedMusicbillInvitations && + playerStore.sharedMusicbillInvitations.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if !playerStore.hasLoadedSharedMusicbillInvitations { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if playerStore.sharedMusicbillInvitations.isEmpty { + ContentUnavailableView( + "No Invitations", + systemImage: "person.2", + description: Text("Shared musicbill invitations will appear here.") + ) + } else { + List { + Section { + Label( + "Invitations expire automatically after 3-4 days.", + systemImage: "clock" + ) + .font(.footnote) + .foregroundStyle(.secondary) + } + + Section("Invitations") { + ForEach(playerStore.sharedMusicbillInvitations) { invitation in + SharedMusicbillInvitationRow( + invitation: invitation, + isAccepting: playerStore.acceptingSharedMusicbillInvitationIDs.contains(invitation.id) + ) { + userForDetail = UserDetailTarget(id: invitation.inviteUserID) + } onAccept: { + let didAccept = await playerStore.acceptSharedMusicbillInvitation(invitation) + if didAccept { + dismiss() + } + } + } + } + } + .refreshable { + await playerStore.loadSharedMusicbillInvitations(force: true) + } + .overlay { + if playerStore.isLoadingSharedMusicbillInvitations { + ProgressView() + .padding(16) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + } +} + +struct SharedMusicbillInvitationRow: View { + let invitation: SharedMusicbillInvitation + let isAccepting: Bool + let onOpenUser: () -> Void + let onAccept: () async -> Void + + private var musicbillName: String { + invitation.musicbillName.isEmpty ? "Musicbill" : invitation.musicbillName + } + + private var inviteDate: Date { + let timestamp = invitation.inviteTimestamp + let seconds = timestamp > 10_000_000_000 ? timestamp / 1000 : timestamp + return Date(timeIntervalSince1970: seconds) + } + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "person.crop.circle.badge.plus") + .font(.title2) + .foregroundStyle(.tint) + .frame(width: 34, height: 34) + + VStack(alignment: .leading, spacing: 4) { + Button(action: onOpenUser) { + Label(invitation.inviteUserNickname, systemImage: "person.crop.circle") + .font(.headline) + .lineLimit(1) + } + .buttonStyle(.plain) + + Text(musicbillName) + .foregroundStyle(.secondary) + .lineLimit(1) + + Text(inviteDate.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(.tertiary) + } + + Spacer() + + Button { + Task { + await onAccept() + } + } label: { + if isAccepting { + ProgressView() + } else { + Label("Accept", systemImage: "checkmark.circle") + } + } + .disabled(isAccepting) + .buttonStyle(.borderedProminent) + } + .padding(.vertical, 4) + } +} + +struct PublicMusicbillCollectionView: View { + @ObservedObject var playerStore: PlayerStore + let showsDoneButton: Bool + let embedsInNavigationStack: Bool + + @Environment(\.dismiss) private var dismiss + @State private var searchText = "" + @State private var musicbillForDetail: PublicMusicbillSearchItem? + + init( + playerStore: PlayerStore, + showsDoneButton: Bool = true, + embedsInNavigationStack: Bool = true + ) { + self.playerStore = playerStore + self.showsDoneButton = showsDoneButton + self.embedsInNavigationStack = embedsInNavigationStack + } + + var body: some View { + Group { + if embedsInNavigationStack { + NavigationStack { + pageContent + } + } else { + pageContent + } + } + .searchable(text: $searchText, prompt: "Collections") + .onSubmit(of: .search) { + Task { + await load(page: 1, force: true) + } + } + .sheet(item: $musicbillForDetail) { musicbill in + PublicMusicbillDetailView( + musicbill: musicbill, + playerStore: playerStore + ) + } + .task { + searchText = playerStore.publicMusicbillCollectionKeyword + if !playerStore.hasLoadedPublicMusicbillCollections { + await load(page: 1) + } + } + } + + private var pageContent: some View { + content + .navigationTitle("Public Collections") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + if showsDoneButton { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + } + + ToolbarItem(placement: .primaryAction) { + Button { + Task { + await load(page: 1, force: true) + } + } label: { + Image(systemName: "magnifyingglass") + } + .disabled(playerStore.isLoadingPublicMusicbillCollections) + .help("Search Collections") + } + } + } + + @ViewBuilder + private var content: some View { + if playerStore.isLoadingPublicMusicbillCollections && + playerStore.publicMusicbillCollections.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if !playerStore.hasLoadedPublicMusicbillCollections { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if playerStore.publicMusicbillCollections.isEmpty { + ContentUnavailableView( + playerStore.publicMusicbillCollectionKeyword.isEmpty ? "No Collections" : "No Results", + systemImage: "star", + description: Text( + playerStore.publicMusicbillCollectionKeyword.isEmpty ? + "Collect public musicbills from Search." : + "Try another collection keyword." + ) + ) + } else { + List { + Section("\(playerStore.publicMusicbillCollectionTotal) Musicbills") { + ForEach(playerStore.publicMusicbillCollections) { collection in + PublicMusicbillSearchRow(musicbill: collection.searchItem) { + musicbillForDetail = collection.searchItem + } + } + } + + if totalPages > 1 { + Section { + HStack { + Button { + Task { + await load(page: playerStore.publicMusicbillCollectionPage - 1) + } + } label: { + Label("Previous", systemImage: "chevron.left") + } + .disabled( + playerStore.publicMusicbillCollectionPage <= 1 || + playerStore.isLoadingPublicMusicbillCollections + ) + + Spacer() + + Text("Page \(playerStore.publicMusicbillCollectionPage) of \(totalPages)") + .font(.footnote.monospacedDigit()) + .foregroundStyle(.secondary) + + Spacer() + + Button { + Task { + await load(page: playerStore.publicMusicbillCollectionPage + 1) + } + } label: { + Label("Next", systemImage: "chevron.right") + } + .disabled( + playerStore.publicMusicbillCollectionPage >= totalPages || + playerStore.isLoadingPublicMusicbillCollections + ) + } + } + } + } + .refreshable { + await load(page: playerStore.publicMusicbillCollectionPage, force: true) + } + .overlay { + if playerStore.isLoadingPublicMusicbillCollections { + ProgressView() + .padding(16) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + } + + private var totalPages: Int { + max( + 1, + Int( + ceil( + Double(playerStore.publicMusicbillCollectionTotal) / + Double(playerStore.publicMusicbillCollectionPageSize) + ) + ) + ) + } + + private func load(page: Int, force: Bool = false) async { + await playerStore.loadPublicMusicbillCollections( + keyword: searchText, + page: page, + force: force + ) + searchText = playerStore.publicMusicbillCollectionKeyword + } +} diff --git a/apps/apple/Cicada/Features/Player/PlayerNowPlayingViews.swift b/apps/apple/Cicada/Features/Player/PlayerNowPlayingViews.swift new file mode 100644 index 000000000..fbe80c10e --- /dev/null +++ b/apps/apple/Cicada/Features/Player/PlayerNowPlayingViews.swift @@ -0,0 +1,979 @@ +import Foundation +import SwiftUI + +enum NowPlayingLyricState: Equatable { + case idle + case loading + case instrumental + case empty + case loaded([LyricItem]) + case failed(String) +} + +struct NowPlayingDetailView: View { + @ObservedObject var playerStore: PlayerStore + @ObservedObject var audioPlayer: AudioPlayerController + @Environment(\.dismiss) private var dismiss + @State private var lyricState: NowPlayingLyricState = .idle + @State private var musicForMusicbillSelection: Music? + @State private var artistForDetail: ArtistSearchItem? + @State private var publicMusicbillForDetail: PublicMusicbillSearchItem? + @State private var userForDetail: UserDetailTarget? + + var body: some View { + NavigationStack { + Group { + if let music = audioPlayer.currentMusic { + ScrollView { + VStack(spacing: 24) { + ArtworkView( + urlString: music.cover.isEmpty ? music.coverThumbnail : music.cover, + systemImage: "music.note", + size: 260 + ) + .shadow(radius: 12, y: 6) + + titleBlock(for: music) + progressBlock + detailTransportControls + metadataBlock(for: music) + lyricsBlock(for: music) + } + .frame(maxWidth: 520) + .frame(maxWidth: .infinity) + .padding() + } + } else { + ContentUnavailableView( + "No Music Playing", + systemImage: "music.note", + description: Text("Play a song before opening Now Playing.") + ) + } + } + .navigationTitle("Now Playing") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + + ToolbarItem(placement: .primaryAction) { + if let music = audioPlayer.currentMusic { + Button { + musicForMusicbillSelection = music + } label: { + Image(systemName: "text.badge.plus") + } + .help("Add to Musicbill") + } + } + } + } + .sheet(item: $musicForMusicbillSelection) { music in + AddToMusicbillSheet( + music: music, + playerStore: playerStore + ) + } + .sheet(item: $artistForDetail) { artist in + ArtistDetailView( + artist: artist, + playerStore: playerStore + ) + } + .sheet(item: $publicMusicbillForDetail) { musicbill in + PublicMusicbillDetailView( + musicbill: musicbill, + playerStore: playerStore + ) + } + .sheet(item: $userForDetail) { target in + UserProfileView( + userID: target.id, + playerStore: playerStore + ) + } + .task(id: audioPlayer.currentMusic?.id) { + guard let music = audioPlayer.currentMusic else { + lyricState = .idle + return + } + await loadLyrics(for: music) + } + .task(id: audioPlayer.currentMusic?.id) { + guard let musicID = audioPlayer.currentMusic?.id else { + return + } + await playerStore.loadMusicDetail(id: musicID) + } + } + + private func titleBlock(for music: Music) -> some View { + VStack(spacing: 6) { + Text(music.name) + .font(.title2.bold()) + .multilineTextAlignment(.center) + + if let alias = music.aliases.first { + Text(alias) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + Text(music.performerLine) + .font(.headline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private var progressBlock: some View { + if audioPlayer.duration > 0 { + VStack(spacing: 8) { + Slider( + value: Binding( + get: { + min(audioPlayer.currentTime, audioPlayer.duration) + }, + set: { value in + audioPlayer.currentTime = value + } + ), + in: 0...max(audioPlayer.duration, 1), + onEditingChanged: { isEditing in + if !isEditing { + audioPlayer.seek(to: audioPlayer.currentTime) + } + } + ) + + HStack { + Text(formatPlaybackTime(audioPlayer.currentTime)) + Spacer() + Text(formatPlaybackTime(audioPlayer.duration)) + } + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + } + + private var detailTransportControls: some View { + HStack(spacing: 24) { + Button { + audioPlayer.previous() + } label: { + Image(systemName: "backward.fill") + } + .help("Previous") + + Button { + audioPlayer.togglePlayback() + } label: { + Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") + .frame(width: 28, height: 28) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .help(audioPlayer.isPlaying ? "Pause" : "Play") + + Button { + audioPlayer.next() + } label: { + Image(systemName: "forward.fill") + } + .help("Next") + } + .font(.title3) + } + + private func metadataBlock(for music: Music) -> some View { + let detail = playerStore.musicDetails[music.id] + let isLoadingDetail = playerStore.loadingMusicDetailIDs.contains(music.id) + + return VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Details") + .font(.headline) + + Spacer() + + if isLoadingDetail { + ProgressView() + .controlSize(.small) + } + } + + VStack(spacing: 10) { + detailRow("Type", music.type == 2 ? "Instrumental" : "Song") + + if let detail { + if let year = detail.year { + detailRow("Year", String(year)) + } + + if detail.createTimestamp > 0 { + detailRow( + "Added", + formatCicadaTimestamp( + detail.createTimestamp, + date: .abbreviated, + time: .omitted + ) + ) + } + + if let assetDurationMs = detail.assetDurationMs { + detailRow("Duration", formatPlaybackTime(Double(assetDurationMs) / 1000)) + } + + if let assetCodec = detail.assetCodec, !assetCodec.isEmpty { + detailRow("Codec", assetCodec.uppercased()) + } + + if let assetBitRate = detail.assetBitRate { + detailRow("Bit Rate", formatAssetBitRate(assetBitRate)) + } + + detailRow("Heat", String(detail.heat)) + detailRow("Musicbills", String(detail.musicbillCount)) + } else { + detailRow("Performers", artistLine(music.performers)) + + if !music.lyricists.isEmpty { + detailRow("Lyricists", artistLine(music.lyricists)) + } + + if !music.composers.isEmpty { + detailRow("Composers", artistLine(music.composers)) + } + } + } + + if let detail { + artistSection("Performers", artists: detail.performers) + artistSection("Lyricists", artists: detail.lyricists) + artistSection("Composers", artists: detail.composers) + relatedPublicMusicbillSection(detail.relatedPublicMusicbillList) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private func artistSection(_ title: String, artists: [ArtistSearchItem]) -> some View { + if !artists.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.headline) + + VStack(spacing: 0) { + ForEach(Array(artists.enumerated()), id: \.element.id) { index, artist in + Button { + artistForDetail = artist + } label: { + MusicDetailArtistRow(artist: artist) + } + .buttonStyle(.plain) + + if index < artists.count - 1 { + Divider() + .padding(.leading, 52) + } + } + } + } + } + } + + @ViewBuilder + private func relatedPublicMusicbillSection(_ musicbills: [PublicMusicbillSearchItem]) -> some View { + if !musicbills.isEmpty { + let visibleMusicbills = Array(musicbills.prefix(5)) + + VStack(alignment: .leading, spacing: 10) { + Text("Related Musicbills") + .font(.headline) + + VStack(spacing: 0) { + ForEach(Array(visibleMusicbills.enumerated()), id: \.element.id) { index, musicbill in + RelatedPublicMusicbillRow( + musicbill: musicbill, + onOpen: { + publicMusicbillForDetail = musicbill + }, + onOpenUser: { + userForDetail = UserDetailTarget(id: musicbill.user.id) + } + ) + + if index < visibleMusicbills.count - 1 { + Divider() + .padding(.leading, 52) + } + } + } + } + } + } + + private func formatAssetBitRate(_ bitRate: Int) -> String { + if bitRate >= 1000 { + return "\(bitRate / 1000) kbps" + } + return "\(bitRate) bps" + } + + private func detailRow(_ title: String, _ value: String) -> some View { + LabeledContent { + Text(value) + .multilineTextAlignment(.trailing) + } label: { + Text(title) + } + .font(.subheadline) + } + + private func lyricsBlock(for music: Music) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Lyrics") + .font(.headline) + + Spacer() + + if case .failed = lyricState { + Button { + Task { + await loadLyrics(for: music) + } + } label: { + Label("Retry", systemImage: "arrow.clockwise") + } + .buttonStyle(.borderless) + } + } + + lyricContent + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var lyricContent: some View { + switch lyricState { + case .idle, .loading: + HStack(spacing: 10) { + ProgressView() + Text("Loading Lyrics") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 120) + + case .instrumental: + Label("Instrumental tracks do not have lyrics.", systemImage: "music.note") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 120) + + case .empty: + Label("No lyrics", systemImage: "text.quote") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 120) + + case .loaded(let lyrics): + let lines = parseLyricItems(lyrics) + if lines.isEmpty { + Label("No lyrics", systemImage: "text.quote") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 120) + } else { + LyricLinesView( + lines: lines, + currentTime: audioPlayer.currentTime + ) + } + + case .failed(let message): + Label(message, systemImage: "exclamationmark.triangle") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 120) + } + } + + private func loadLyrics(for music: Music) async { + lyricState = .loading + let result = await playerStore.loadLyrics(for: music) + guard !Task.isCancelled, audioPlayer.currentMusic?.id == music.id else { + return + } + + switch result { + case .instrumental: + lyricState = .instrumental + case .empty: + lyricState = .empty + case .loaded(let lyrics): + lyricState = .loaded(lyrics) + case .failed(let message): + lyricState = .failed(message) + } + } + + private func artistLine(_ artists: [ArtistSummary]) -> String { + artists.map(\.name).joined(separator: ", ") + } +} + +struct MusicDetailArtistRow: View { + let artist: ArtistSearchItem + + var body: some View { + HStack(spacing: 12) { + ArtworkView( + urlString: artist.avatar, + systemImage: "person.crop.square", + size: 40 + ) + + VStack(alignment: .leading, spacing: 3) { + Text(artist.name) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + + if !artist.aliases.isEmpty { + Text(artist.aliases.joined(separator: " / ")) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 8) + .contentShape(Rectangle()) + } +} + +struct RelatedPublicMusicbillRow: View { + let musicbill: PublicMusicbillSearchItem + let onOpen: () -> Void + let onOpenUser: () -> Void + + var body: some View { + HStack(spacing: 8) { + Button(action: onOpen) { + HStack(spacing: 12) { + ArtworkView( + urlString: musicbill.cover, + placeholderURLString: musicbill.coverThumbnail, + systemImage: "music.note.list", + size: 40 + ) + + VStack(alignment: .leading, spacing: 3) { + Text(musicbill.name) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + Text(musicbill.user.nickname) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text("\(musicbill.musicCount)") + .font(.subheadline.monospacedDigit().weight(.semibold)) + Text("Songs") + .font(.caption) + .foregroundStyle(.secondary) + } + + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Button(action: onOpenUser) { + Image(systemName: "person.crop.circle") + .font(.body) + .frame(width: 28, height: 28) + } + .buttonStyle(.borderless) + .help("Open Owner") + } + .padding(.vertical, 8) + } +} + +struct QueueView: View { + @ObservedObject var audioPlayer: AudioPlayerController + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Group { + if audioPlayer.queue.isEmpty { + ContentUnavailableView( + "Queue Empty", + systemImage: "list.bullet", + description: Text("Play a song to start a queue.") + ) + } else { + List { + Section("\(audioPlayer.queue.count) Songs") { + ForEach(Array(audioPlayer.queue.enumerated()), id: \.offset) { index, music in + Button { + audioPlayer.playQueueItem(at: index) + } label: { + QueueRow( + index: index, + music: music, + isCurrent: index == audioPlayer.currentQueueIndex, + isPlaying: index == audioPlayer.currentQueueIndex && audioPlayer.isPlaying + ) + } + .buttonStyle(.plain) + } + } + } + } + } + .navigationTitle("Queue") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + } + } + } +} + +struct QueueRow: View { + let index: Int + let music: Music + let isCurrent: Bool + let isPlaying: Bool + + var body: some View { + HStack(spacing: 12) { + Text("\(index + 1)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 28, alignment: .trailing) + + ArtworkView( + urlString: music.coverThumbnail?.isEmpty == false ? music.coverThumbnail : music.cover, + systemImage: "music.note", + size: 44 + ) + + VStack(alignment: .leading, spacing: 3) { + Text(music.name) + .lineLimit(1) + Text(music.performerLine) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + if isCurrent { + Image(systemName: isPlaying ? "speaker.wave.2.fill" : "pause.circle") + .foregroundStyle(.tint) + } + } + .contentShape(Rectangle()) + } +} + +struct LyricTimelineLine: Identifiable, Equatable { + let id: String + let time: Double? + let texts: [String] +} + +struct LyricLinesView: View { + let lines: [LyricTimelineLine] + let currentTime: Double + + private var activeLineID: String? { + lines + .filter { line in + guard let time = line.time else { return false } + return time <= currentTime + 0.25 + } + .last? + .id + } + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 14) { + ForEach(lines) { line in + lyricLine(line) + .id(line.id) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + } + .frame(height: 320) + .onAppear { + scrollToActiveLine(with: proxy) + } + .onChange(of: activeLineID) { _, _ in + scrollToActiveLine(with: proxy) + } + } + } + + private func lyricLine(_ line: LyricTimelineLine) -> some View { + let isActive = line.id == activeLineID + + return VStack(spacing: 4) { + ForEach(Array(line.texts.enumerated()), id: \.offset) { _, text in + Text(text) + .font(isActive ? .headline : .body) + .fontWeight(isActive ? .semibold : .regular) + .foregroundStyle(isActive ? Color.accentColor : Color.primary) + .multilineTextAlignment(.center) + .lineLimit(nil) + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 8) + .animation(.easeInOut(duration: 0.2), value: isActive) + } + + private func scrollToActiveLine(with proxy: ScrollViewProxy) { + guard let activeLineID else { return } + withAnimation(.easeInOut(duration: 0.25)) { + proxy.scrollTo(activeLineID, anchor: .center) + } + } +} + +struct MiniPlayerView: View { + @ObservedObject var audioPlayer: AudioPlayerController + let onDetails: () -> Void + let onQueue: () -> Void + + var body: some View { + if let music = audioPlayer.currentMusic { + VStack(spacing: 8) { + Divider() + ViewThatFits(in: .horizontal) { + regularControls(for: music) + compactControls(for: music) + } + + if audioPlayer.duration > 0 { + HStack(spacing: 10) { + Text(formatPlaybackTime(audioPlayer.currentTime)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + + Slider( + value: Binding( + get: { + min(audioPlayer.currentTime, audioPlayer.duration) + }, + set: { value in + audioPlayer.currentTime = value + } + ), + in: 0...max(audioPlayer.duration, 1), + onEditingChanged: { isEditing in + if !isEditing { + audioPlayer.seek(to: audioPlayer.currentTime) + } + } + ) + + Text(formatPlaybackTime(audioPlayer.duration)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + } + .padding(.horizontal) + .padding(.top, 8) + .padding(.bottom, 6) + .background(.bar) + } + } + + private func regularControls(for music: Music) -> some View { + HStack(spacing: 12) { + nowPlayingButton(for: music, artworkSize: 44) + + Spacer() + + transportControls + queueButton + } + } + + private func compactControls(for music: Music) -> some View { + VStack(spacing: 8) { + HStack(spacing: 12) { + nowPlayingButton(for: music, artworkSize: 40) + + Spacer() + + queueButton + } + + transportControls + } + } + + private func nowPlayingButton(for music: Music, artworkSize: CGFloat) -> some View { + Button(action: onDetails) { + nowPlayingSummary(for: music, artworkSize: artworkSize) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .help("Now Playing") + } + + private func nowPlayingSummary(for music: Music, artworkSize: CGFloat) -> some View { + HStack(spacing: 12) { + ArtworkView( + urlString: music.coverThumbnail?.isEmpty == false ? music.coverThumbnail : music.cover, + systemImage: "music.note", + size: artworkSize + ) + + VStack(alignment: .leading, spacing: 2) { + Text(music.name) + .font(.headline) + .lineLimit(1) + Text(music.performerLine) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(minWidth: 0, alignment: .leading) + } + } + + private var transportControls: some View { + HStack(spacing: 12) { + Button { + audioPlayer.previous() + } label: { + Image(systemName: "backward.fill") + } + .help("Previous") + + Button { + audioPlayer.togglePlayback() + } label: { + Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") + } + .buttonStyle(.borderedProminent) + .help(audioPlayer.isPlaying ? "Pause" : "Play") + + Button { + audioPlayer.next() + } label: { + Image(systemName: "forward.fill") + } + .help("Next") + } + } + + private var queueButton: some View { + Button(action: onQueue) { + Image(systemName: "list.bullet") + } + .disabled(audioPlayer.queue.isEmpty) + .help("Queue") + } + +} + +struct ParsedLyricEntry { + let timeMillis: Int? + let text: String + let sourceIndex: Int + let sequence: Int +} + +func parseLyricItems(_ lyrics: [LyricItem]) -> [LyricTimelineLine] { + let entries = lyrics.enumerated().flatMap { sourceIndex, lyric in + parseLRC(lyric.lrc, sourceIndex: sourceIndex) + } + let timedEntries = entries.filter { $0.timeMillis != nil } + + guard !timedEntries.isEmpty else { + return entries + .map(\.text) + .filter { !$0.isEmpty } + .enumerated() + .map { index, text in + LyricTimelineLine( + id: "static-\(index)", + time: nil, + texts: [text] + ) + } + } + + let groupedEntries = Dictionary(grouping: timedEntries) { entry in + entry.timeMillis ?? 0 + } + + return groupedEntries.keys.sorted().map { timeMillis in + let texts = groupedEntries[timeMillis, default: []] + .sorted { lhs, rhs in + if lhs.sourceIndex != rhs.sourceIndex { + return lhs.sourceIndex < rhs.sourceIndex + } + return lhs.sequence < rhs.sequence + } + .map(\.text) + .filter { !$0.isEmpty } + + return LyricTimelineLine( + id: "timed-\(timeMillis)", + time: Double(timeMillis) / 1000, + texts: texts.isEmpty ? [" "] : texts + ) + } +} + +func parseLRC(_ lrc: String, sourceIndex: Int) -> [ParsedLyricEntry] { + let pattern = #"\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return [] + } + + return lrc + .components(separatedBy: .newlines) + .enumerated() + .flatMap { sequence, rawLine -> [ParsedLyricEntry] in + let trimmedLine = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedLine.isEmpty else { return [] } + + let line = rawLine as NSString + let range = NSRange(location: 0, length: line.length) + let matches = regex.matches(in: rawLine, range: range) + + guard !matches.isEmpty else { + if trimmedLine.hasPrefix("[") && trimmedLine.hasSuffix("]") { + return [] + } + return [ + ParsedLyricEntry( + timeMillis: nil, + text: trimmedLine, + sourceIndex: sourceIndex, + sequence: sequence + ), + ] + } + + let textStart = matches.map { NSMaxRange($0.range) }.max() ?? 0 + let text = line + .substring(from: min(textStart, line.length)) + .trimmingCharacters(in: .whitespacesAndNewlines) + + return matches.compactMap { match in + guard let timeMillis = lyricTimeMillis(from: match, in: line) else { + return nil + } + return ParsedLyricEntry( + timeMillis: timeMillis, + text: text, + sourceIndex: sourceIndex, + sequence: sequence + ) + } + } +} + +func lyricTimeMillis(from match: NSTextCheckingResult, in line: NSString) -> Int? { + guard match.numberOfRanges >= 3 else { return nil } + guard + let minutes = Int(line.substring(with: match.range(at: 1))), + let seconds = Int(line.substring(with: match.range(at: 2))) + else { + return nil + } + + var milliseconds = 0 + if match.numberOfRanges > 3 { + let fractionRange = match.range(at: 3) + if fractionRange.location != NSNotFound, + let rawMilliseconds = Int(line.substring(with: fractionRange)) { + switch fractionRange.length { + case 1: + milliseconds = rawMilliseconds * 100 + case 2: + milliseconds = rawMilliseconds * 10 + default: + milliseconds = rawMilliseconds + } + } + } + + return ((minutes * 60) + seconds) * 1000 + milliseconds +} + +func dateFromCicadaTimestamp(_ timestamp: TimeInterval) -> Date { + let seconds = timestamp > 10_000_000_000 ? timestamp / 1000 : timestamp + return Date(timeIntervalSince1970: seconds) +} + +func formatCicadaTimestamp( + _ timestamp: TimeInterval, + date: Date.FormatStyle.DateStyle, + time: Date.FormatStyle.TimeStyle +) -> String { + dateFromCicadaTimestamp(timestamp).formatted(date: date, time: time) +} + +func displayDeviceName(_ session: AuthSession) -> String { + let name = session.deviceName.trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? "Unknown Device" : name +} + +func formatPlaybackTime(_ seconds: Double) -> String { + guard seconds.isFinite else { return "0:00" } + let totalSeconds = max(0, Int(seconds.rounded())) + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let remainingSeconds = totalSeconds % 60 + + if hours > 0 { + return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", remainingSeconds))" + } + return "\(minutes):\(String(format: "%02d", remainingSeconds))" +} diff --git a/apps/apple/Cicada/Features/Player/PlayerSearchViews.swift b/apps/apple/Cicada/Features/Player/PlayerSearchViews.swift new file mode 100644 index 000000000..99648c13f --- /dev/null +++ b/apps/apple/Cicada/Features/Player/PlayerSearchViews.swift @@ -0,0 +1,1008 @@ +import Foundation +import SwiftUI + +enum PlayerSearchTab: String, CaseIterable, Identifiable { + case music = "Music" + case artists = "Artists" + case publicMusicbills = "Public" + case lyrics = "Lyrics" + + var id: String { + rawValue + } + + var prompt: String { + switch self { + case .music, .artists, .lyrics: + return rawValue + case .publicMusicbills: + return "Public Musicbills" + } + } +} + +struct SearchMusicView: View { + @ObservedObject var playerStore: PlayerStore + let showsDoneButton: Bool + let embedsInNavigationStack: Bool + + @Environment(\.dismiss) private var dismiss + @State private var selectedTab = PlayerSearchTab.music + @State private var searchText = "" + @State private var musicForMusicbillSelection: Music? + @State private var artistForDetail: ArtistSearchItem? + @State private var publicMusicbillForDetail: PublicMusicbillSearchItem? + + init( + playerStore: PlayerStore, + showsDoneButton: Bool = true, + embedsInNavigationStack: Bool = true + ) { + self.playerStore = playerStore + self.showsDoneButton = showsDoneButton + self.embedsInNavigationStack = embedsInNavigationStack + } + + var body: some View { + platformSearchPage + .onChange(of: selectedTab) { _, _ in + syncSearchTextWithSelectedTab() + } + .task { + syncSearchTextWithSelectedTab() + } + .sheet(item: $musicForMusicbillSelection) { music in + AddToMusicbillSheet( + music: music, + playerStore: playerStore + ) + } + .sheet(item: $artistForDetail) { artist in + ArtistDetailView( + artist: artist, + playerStore: playerStore + ) + } + .sheet(item: $publicMusicbillForDetail) { musicbill in + PublicMusicbillDetailView( + musicbill: musicbill, + playerStore: playerStore + ) + } + } + + @ViewBuilder + private var platformSearchPage: some View { + #if os(macOS) + baseSearchPage + #else + baseSearchPage + .searchable(text: $searchText, prompt: selectedTab.prompt) + .onSubmit(of: .search) { + Task { + await search(page: 1) + } + } + #endif + } + + @ViewBuilder + private var baseSearchPage: some View { + if embedsInNavigationStack { + NavigationStack { + pageContent + } + } else { + pageContent + } + } + + private var pageContent: some View { + VStack(spacing: 0) { + #if os(macOS) + searchHeader + #endif + + if isSearchMode { + Picker("Search Type", selection: $selectedTab) { + ForEach(PlayerSearchTab.allCases) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + .padding() + } + + content + } + #if os(macOS) + .navigationTitle("") + #else + .navigationTitle("Explore") + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + if showsDoneButton { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + } + + #if !os(macOS) + ToolbarItem(placement: .primaryAction) { + if isSearchMode { + Button { + Task { + await search(page: 1) + } + } label: { + Image(systemName: "magnifyingglass") + } + .disabled(trimmedSearchText.isEmpty || isSearching) + .help("Search") + } else { + #if !os(macOS) + Button { + Task { + await playerStore.loadExploration(force: true) + } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(playerStore.isLoadingExploration) + .help("Refresh") + #endif + } + } + #endif + } + } + + #if os(macOS) + private var searchHeader: some View { + HStack(spacing: 16) { + Text("Explore") + .font(.largeTitle.weight(.semibold)) + + Spacer(minLength: 24) + + macSearchField + .frame(width: 320) + } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 12) + } + + private var macSearchField: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + + TextField(selectedTab.prompt, text: $searchText) + .textFieldStyle(.plain) + .onSubmit { + Task { + await search(page: 1) + } + } + + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Clear Search") + } + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(Color.cicadaSecondaryBackground) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + } + } + #endif + + @ViewBuilder + private var content: some View { + if !isSearchMode { + ExplorationContentView(playerStore: playerStore) + } else { + switch selectedTab { + case .music: + musicContent + case .artists: + artistContent + case .publicMusicbills: + publicMusicbillContent + case .lyrics: + lyricContent + } + } + } + + @ViewBuilder + private var musicContent: some View { + if playerStore.isSearchingMusic && playerStore.searchMusicResults.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if !playerStore.hasSearchedMusic { + ContentUnavailableView( + "Search Music", + systemImage: "magnifyingglass", + description: Text("Search by song, alias, artist, lyricist, or composer.") + ) + } else if playerStore.searchMusicResults.isEmpty { + ContentUnavailableView( + "No Results", + systemImage: "music.note", + description: Text("Try another keyword.") + ) + } else { + List { + Section("\(playerStore.searchMusicTotal) Songs") { + ForEach(playerStore.searchMusicResults) { music in + MusicRow( + music: music, + isCurrent: playerStore.audioPlayer.currentMusic?.id == music.id, + isPlaying: playerStore.audioPlayer.currentMusic?.id == music.id && playerStore.audioPlayer.isPlaying + ) { + playerStore.play(music: music, in: playerStore.searchMusicResults) + } onAddToMusicbill: { + musicForMusicbillSelection = music + } onSaveOffline: { + playerStore.saveOffline(music) + } + } + } + + if musicTotalPages > 1 { + Section { + HStack { + Button { + Task { + await search(page: playerStore.searchMusicPage - 1) + } + } label: { + Label("Previous", systemImage: "chevron.left") + } + .disabled(playerStore.searchMusicPage <= 1 || playerStore.isSearchingMusic) + + Spacer() + + Text("Page \(playerStore.searchMusicPage) of \(musicTotalPages)") + .font(.footnote.monospacedDigit()) + .foregroundStyle(.secondary) + + Spacer() + + Button { + Task { + await search(page: playerStore.searchMusicPage + 1) + } + } label: { + Label("Next", systemImage: "chevron.right") + } + .disabled(playerStore.searchMusicPage >= musicTotalPages || playerStore.isSearchingMusic) + } + } + } + } + .overlay { + if playerStore.isSearchingMusic { + ProgressView() + .padding(16) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + } + + @ViewBuilder + private var artistContent: some View { + if playerStore.isSearchingArtists && playerStore.searchArtistResults.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if !playerStore.hasSearchedArtists { + ContentUnavailableView( + "Search Artists", + systemImage: "person.2", + description: Text("Search by artist name or alias.") + ) + } else if playerStore.searchArtistResults.isEmpty { + ContentUnavailableView( + "No Results", + systemImage: "person.crop.circle", + description: Text("Try another artist keyword.") + ) + } else { + List { + Section("\(playerStore.searchArtistTotal) Artists") { + ForEach(playerStore.searchArtistResults) { artist in + ArtistSearchRow(artist: artist) { + artistForDetail = artist + } + } + } + + if artistTotalPages > 1 { + Section { + HStack { + Button { + Task { + await search(page: playerStore.searchArtistPage - 1) + } + } label: { + Label("Previous", systemImage: "chevron.left") + } + .disabled(playerStore.searchArtistPage <= 1 || playerStore.isSearchingArtists) + + Spacer() + + Text("Page \(playerStore.searchArtistPage) of \(artistTotalPages)") + .font(.footnote.monospacedDigit()) + .foregroundStyle(.secondary) + + Spacer() + + Button { + Task { + await search(page: playerStore.searchArtistPage + 1) + } + } label: { + Label("Next", systemImage: "chevron.right") + } + .disabled(playerStore.searchArtistPage >= artistTotalPages || playerStore.isSearchingArtists) + } + } + } + } + .overlay { + if playerStore.isSearchingArtists { + ProgressView() + .padding(16) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + } + + @ViewBuilder + private var publicMusicbillContent: some View { + if playerStore.isSearchingPublicMusicbills && playerStore.searchPublicMusicbillResults.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if !playerStore.hasSearchedPublicMusicbills { + ContentUnavailableView( + "Search Public Musicbills", + systemImage: "music.note.list", + description: Text("Search public musicbills by name.") + ) + } else if playerStore.searchPublicMusicbillResults.isEmpty { + ContentUnavailableView( + "No Results", + systemImage: "music.note.list", + description: Text("Try another musicbill keyword.") + ) + } else { + List { + Section("\(playerStore.searchPublicMusicbillTotal) Musicbills") { + ForEach(playerStore.searchPublicMusicbillResults) { musicbill in + PublicMusicbillSearchRow(musicbill: musicbill) { + publicMusicbillForDetail = musicbill + } + } + } + + if publicMusicbillTotalPages > 1 { + Section { + HStack { + Button { + Task { + await search(page: playerStore.searchPublicMusicbillPage - 1) + } + } label: { + Label("Previous", systemImage: "chevron.left") + } + .disabled(playerStore.searchPublicMusicbillPage <= 1 || playerStore.isSearchingPublicMusicbills) + + Spacer() + + Text("Page \(playerStore.searchPublicMusicbillPage) of \(publicMusicbillTotalPages)") + .font(.footnote.monospacedDigit()) + .foregroundStyle(.secondary) + + Spacer() + + Button { + Task { + await search(page: playerStore.searchPublicMusicbillPage + 1) + } + } label: { + Label("Next", systemImage: "chevron.right") + } + .disabled(playerStore.searchPublicMusicbillPage >= publicMusicbillTotalPages || playerStore.isSearchingPublicMusicbills) + } + } + } + } + .overlay { + if playerStore.isSearchingPublicMusicbills { + ProgressView() + .padding(16) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + } + + @ViewBuilder + private var lyricContent: some View { + if playerStore.isSearchingLyrics && playerStore.searchLyricResults.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if !playerStore.hasSearchedLyrics { + ContentUnavailableView( + "Search Lyrics", + systemImage: "text.quote", + description: Text("Search matching lyric lines across songs.") + ) + } else if playerStore.searchLyricResults.isEmpty { + ContentUnavailableView( + "No Results", + systemImage: "text.quote", + description: Text("Try another lyric keyword.") + ) + } else { + List { + Section("\(playerStore.searchLyricTotal) Songs") { + ForEach(playerStore.searchLyricResults) { result in + MusicRow( + music: result.music, + isCurrent: playerStore.audioPlayer.currentMusic?.id == result.music.id, + isPlaying: playerStore.audioPlayer.currentMusic?.id == result.music.id && playerStore.audioPlayer.isPlaying, + lyricSnippet: result.snippetLines, + lyricKeyword: playerStore.searchLyricKeyword + ) { + playerStore.play( + music: result.music, + in: playerStore.searchLyricResults.map(\.music) + ) + } onAddToMusicbill: { + musicForMusicbillSelection = result.music + } onSaveOffline: { + playerStore.saveOffline(result.music) + } + } + } + + if lyricTotalPages > 1 { + Section { + HStack { + Button { + Task { + await search(page: playerStore.searchLyricPage - 1) + } + } label: { + Label("Previous", systemImage: "chevron.left") + } + .disabled(playerStore.searchLyricPage <= 1 || playerStore.isSearchingLyrics) + + Spacer() + + Text("Page \(playerStore.searchLyricPage) of \(lyricTotalPages)") + .font(.footnote.monospacedDigit()) + .foregroundStyle(.secondary) + + Spacer() + + Button { + Task { + await search(page: playerStore.searchLyricPage + 1) + } + } label: { + Label("Next", systemImage: "chevron.right") + } + .disabled(playerStore.searchLyricPage >= lyricTotalPages || playerStore.isSearchingLyrics) + } + } + } + } + .overlay { + if playerStore.isSearchingLyrics { + ProgressView() + .padding(16) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + } + + private var trimmedSearchText: String { + searchText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var isSearchMode: Bool { + !trimmedSearchText.isEmpty + } + + private var isSearching: Bool { + switch selectedTab { + case .music: + return playerStore.isSearchingMusic + case .artists: + return playerStore.isSearchingArtists + case .publicMusicbills: + return playerStore.isSearchingPublicMusicbills + case .lyrics: + return playerStore.isSearchingLyrics + } + } + + private var musicTotalPages: Int { + totalPages(total: playerStore.searchMusicTotal, pageSize: playerStore.searchMusicPageSize) + } + + private var artistTotalPages: Int { + totalPages(total: playerStore.searchArtistTotal, pageSize: playerStore.searchArtistPageSize) + } + + private var publicMusicbillTotalPages: Int { + totalPages(total: playerStore.searchPublicMusicbillTotal, pageSize: playerStore.searchPublicMusicbillPageSize) + } + + private var lyricTotalPages: Int { + totalPages(total: playerStore.searchLyricTotal, pageSize: playerStore.searchLyricPageSize) + } + + private func search(page: Int) async { + guard !trimmedSearchText.isEmpty else { return } + + switch selectedTab { + case .music: + await playerStore.searchMusic(keyword: searchText, page: page) + if !playerStore.searchMusicKeyword.isEmpty { + searchText = playerStore.searchMusicKeyword + } + case .artists: + await playerStore.searchArtists(keyword: searchText, page: page) + if !playerStore.searchArtistKeyword.isEmpty { + searchText = playerStore.searchArtistKeyword + } + case .publicMusicbills: + await playerStore.searchPublicMusicbills(keyword: searchText, page: page) + if !playerStore.searchPublicMusicbillKeyword.isEmpty { + searchText = playerStore.searchPublicMusicbillKeyword + } + case .lyrics: + await playerStore.searchLyrics(keyword: searchText, page: page) + if !playerStore.searchLyricKeyword.isEmpty { + searchText = playerStore.searchLyricKeyword + } + } + } + + private func syncSearchTextWithSelectedTab() { + switch selectedTab { + case .music: + if !playerStore.searchMusicKeyword.isEmpty { + searchText = playerStore.searchMusicKeyword + } + case .artists: + if !playerStore.searchArtistKeyword.isEmpty { + searchText = playerStore.searchArtistKeyword + } + case .publicMusicbills: + if !playerStore.searchPublicMusicbillKeyword.isEmpty { + searchText = playerStore.searchPublicMusicbillKeyword + } + case .lyrics: + if !playerStore.searchLyricKeyword.isEmpty { + searchText = playerStore.searchLyricKeyword + } + } + } + + private func totalPages(total: Int, pageSize: Int) -> Int { + max(1, Int(ceil(Double(total) / Double(pageSize)))) + } +} + +struct ArtistSearchRow: View { + let artist: ArtistSearchItem + let onOpen: () -> Void + + var body: some View { + Button(action: onOpen) { + HStack(spacing: 12) { + ArtworkView( + urlString: artist.avatar, + systemImage: "person.crop.square", + size: 50 + ) + + VStack(alignment: .leading, spacing: 4) { + Text(artist.name) + .font(.headline) + .lineLimit(1) + + if !artist.aliases.isEmpty { + Text(artist.aliases.joined(separator: " / ")) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text("\(artist.musicCount)") + .font(.headline.monospacedDigit()) + Text("Songs") + .font(.caption) + .foregroundStyle(.secondary) + } + + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +enum ArtistMusicRole: String, CaseIterable, Identifiable { + case performer = "Performed" + case lyricist = "Lyrics" + case composer = "Composed" + + var id: String { + rawValue + } +} + +struct ArtistDetailView: View { + let artist: ArtistSearchItem + @ObservedObject var playerStore: PlayerStore + + @Environment(\.dismiss) private var dismiss + @State private var selectedRole = ArtistMusicRole.performer + @State private var musicForMusicbillSelection: Music? + + private var detail: ArtistDetail? { + playerStore.artistDetails[artist.id] + } + + var body: some View { + NavigationStack { + Group { + if let detail { + detailContent(detail) + } else if playerStore.loadingArtistIDs.contains(artist.id) { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView( + "Artist Not Loaded", + systemImage: "person.crop.circle.badge.exclamationmark", + description: Text("Pull to refresh or try again.") + ) + } + } + .navigationTitle(detail?.name ?? artist.name) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + } + } + .sheet(item: $musicForMusicbillSelection) { music in + AddToMusicbillSheet( + music: music, + playerStore: playerStore + ) + } + .task(id: artist.id) { + await playerStore.loadArtist(id: artist.id) + } + } + + private func detailContent(_ detail: ArtistDetail) -> some View { + let roles = availableRoles(for: detail) + let role = roles.contains(selectedRole) ? selectedRole : roles.first ?? .performer + let musicList = musicList(for: role, in: detail) + + return List { + Section { + HStack(spacing: 14) { + ArtworkView( + urlString: detail.avatar.isEmpty ? artist.avatar : detail.avatar, + systemImage: "person.crop.square", + size: 72 + ) + + VStack(alignment: .leading, spacing: 5) { + Text(detail.name) + .font(.headline) + + if !detail.aliases.isEmpty { + Text(detail.aliases.joined(separator: " / ")) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Text("\(uniqueMusicList(in: detail).count) songs") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + if roles.count > 1 { + Section { + Picker("Music Role", selection: $selectedRole) { + ForEach(roles) { role in + Text(role.rawValue).tag(role) + } + } + .pickerStyle(.segmented) + } + } + + Section(role.rawValue) { + if musicList.isEmpty { + ContentUnavailableView( + "No Songs", + systemImage: "music.note", + description: Text("This artist has no songs in this category.") + ) + } else { + ForEach(musicList) { music in + MusicRow( + music: music, + isCurrent: playerStore.audioPlayer.currentMusic?.id == music.id, + isPlaying: playerStore.audioPlayer.currentMusic?.id == music.id && playerStore.audioPlayer.isPlaying + ) { + playerStore.play(music: music, in: musicList) + } onAddToMusicbill: { + musicForMusicbillSelection = music + } onSaveOffline: { + playerStore.saveOffline(music) + } + } + } + } + } + .refreshable { + await playerStore.loadArtist(id: artist.id, force: true) + } + } + + private func availableRoles(for detail: ArtistDetail) -> [ArtistMusicRole] { + ArtistMusicRole.allCases.filter { role in + !musicList(for: role, in: detail).isEmpty + } + } + + private func musicList(for role: ArtistMusicRole, in detail: ArtistDetail) -> [Music] { + switch role { + case .performer: + return detail.performerMusicList + case .lyricist: + return detail.lyricistMusicList + case .composer: + return detail.composerMusicList + } + } + + private func uniqueMusicList(in detail: ArtistDetail) -> [Music] { + var seenIDs = Set() + return (detail.performerMusicList + detail.lyricistMusicList + detail.composerMusicList) + .filter { music in + seenIDs.insert(music.id).inserted + } + } +} + +struct PublicMusicbillSearchRow: View { + let musicbill: PublicMusicbillSearchItem + let onOpen: () -> Void + + var body: some View { + Button(action: onOpen) { + HStack(spacing: 12) { + ArtworkView( + urlString: musicbill.cover, + placeholderURLString: musicbill.coverThumbnail, + systemImage: "music.note.list", + size: 50 + ) + + VStack(alignment: .leading, spacing: 4) { + Text(musicbill.name) + .font(.headline) + .lineLimit(1) + Text(musicbill.user.nickname) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text("\(musicbill.musicCount)") + .font(.headline.monospacedDigit()) + Text("Songs") + .font(.caption) + .foregroundStyle(.secondary) + } + + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +struct PublicMusicbillDetailView: View { + let musicbill: PublicMusicbillSearchItem + @ObservedObject var playerStore: PlayerStore + + @Environment(\.dismiss) private var dismiss + @State private var musicForMusicbillSelection: Music? + @State private var userForDetail: UserDetailTarget? + + private var detail: PublicMusicbillDetail? { + playerStore.publicMusicbillDetails[musicbill.id] + } + + var body: some View { + NavigationStack { + Group { + if let detail { + detailContent(detail) + } else if playerStore.loadingPublicMusicbillIDs.contains(musicbill.id) { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView( + "Musicbill Not Loaded", + systemImage: "music.note.list", + description: Text("Pull to refresh or try again.") + ) + } + } + .navigationTitle(detail?.name ?? musicbill.name) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + + if let detail { + ToolbarItem(placement: .primaryAction) { + Button { + Task { + await playerStore.setPublicMusicbillCollected( + id: detail.id, + collected: !detail.collected + ) + } + } label: { + Image(systemName: detail.collected ? "star.fill" : "star") + } + .disabled(playerStore.collectingPublicMusicbillIDs.contains(detail.id)) + .help(detail.collected ? "Uncollect Musicbill" : "Collect Musicbill") + } + } + } + } + .sheet(item: $musicForMusicbillSelection) { music in + AddToMusicbillSheet( + music: music, + playerStore: playerStore + ) + } + .sheet(item: $userForDetail) { target in + UserProfileView( + userID: target.id, + playerStore: playerStore + ) + } + .task(id: musicbill.id) { + await playerStore.loadPublicMusicbill(id: musicbill.id) + } + } + + private func detailContent(_ detail: PublicMusicbillDetail) -> some View { + List { + Section { + HStack(spacing: 14) { + ArtworkView( + urlString: detail.cover.isEmpty ? musicbill.cover : detail.cover, + placeholderURLString: detail.coverThumbnail?.isEmpty == false ? detail.coverThumbnail : musicbill.coverThumbnail, + systemImage: "music.note.list", + size: 72 + ) + + VStack(alignment: .leading, spacing: 5) { + Text(detail.name) + .font(.headline) + Button { + userForDetail = UserDetailTarget(id: detail.user.id) + } label: { + Label(detail.user.nickname, systemImage: "person.crop.circle") + .lineLimit(1) + } + .buttonStyle(.plain) + .font(.subheadline) + .foregroundStyle(.secondary) + HStack(spacing: 10) { + Text("\(detail.musicList.count) songs") + Label(detail.collected ? "Collected" : "Public", systemImage: detail.collected ? "star.fill" : "globe") + } + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + Section("Songs") { + if detail.musicList.isEmpty { + ContentUnavailableView( + "No Songs", + systemImage: "music.note", + description: Text("This public musicbill does not contain songs.") + ) + } else { + ForEach(detail.musicList) { music in + MusicRow( + music: music, + isCurrent: playerStore.audioPlayer.currentMusic?.id == music.id, + isPlaying: playerStore.audioPlayer.currentMusic?.id == music.id && playerStore.audioPlayer.isPlaying + ) { + playerStore.play(music: music, in: detail.musicList) + } onAddToMusicbill: { + musicForMusicbillSelection = music + } onSaveOffline: { + playerStore.saveOffline(music) + } + } + } + } + } + .refreshable { + await playerStore.loadPublicMusicbill(id: detail.id, force: true) + } + } +} diff --git a/apps/apple/Cicada/Features/Player/PlayerSidebar.swift b/apps/apple/Cicada/Features/Player/PlayerSidebar.swift new file mode 100644 index 000000000..085639992 --- /dev/null +++ b/apps/apple/Cicada/Features/Player/PlayerSidebar.swift @@ -0,0 +1,348 @@ +import Foundation +import SwiftUI + +enum PlayerContentSelection: Hashable { + case exploreSearch + case settings + case profile + case sharedInvitations + case publicCollections + case musicbill(MusicbillSummary.ID) +} + +struct PlayerSidebar: View { + let server: ServerRecord + let user: ServerUserRecord + let versionWarning: String? + let metadataError: String? + let isRefreshingMetadata: Bool + let isLoadingMusicbillList: Bool + let musicbills: [MusicbillSummary] + @Binding var selectedContent: PlayerContentSelection? + let onCreateMusicbill: () -> Void + let onRefreshMusicbills: () async -> Void + let onRetryServerCheck: () -> Void + let onSwitchAccount: () -> Void + let onChangeServer: () -> Void + let onSignOut: () -> Void + + var body: some View { + List(selection: $selectedContent) { + #if !os(macOS) + Section { + accountRow + } + #endif + + serverStatusSection + + Section("Browse") { + SidebarSettingsLabel( + title: "Explore", + systemImage: "magnifyingglass", + tint: .primary + ) + .tag(PlayerContentSelection.exploreSearch) + .help("Explore") + + SidebarSettingsLabel( + title: "Settings", + systemImage: "gearshape", + tint: .primary + ) + .tag(PlayerContentSelection.settings) + .help("Settings") + } + + Section { + MusicbillActionRow( + isRefreshing: isLoadingMusicbillList, + onCreate: onCreateMusicbill, + onRefresh: { + Task { + await onRefreshMusicbills() + } + } + ) + + SidebarSettingsLabel( + title: "Shared Invitations", + systemImage: "person.2", + tint: .primary + ) + .tag(PlayerContentSelection.sharedInvitations) + .help("Shared Invitations") + + SidebarSettingsLabel( + title: "Public Collections", + systemImage: "star", + tint: .primary + ) + .tag(PlayerContentSelection.publicCollections) + .help("Public Collections") + + if isLoadingMusicbillList && musicbills.isEmpty { + ProgressView("Loading musicbills") + } else if musicbills.isEmpty { + SidebarSettingsLabel( + title: "No musicbills", + systemImage: "music.note.list", + tint: .secondary + ) + } + + ForEach(musicbills) { musicbill in + MusicbillSidebarRow(musicbill: musicbill) + .tag(PlayerContentSelection.musicbill(musicbill.id)) + } + } header: { + Text("Musicbills") + } + } + .navigationTitle("Cicada") + .listStyle(.sidebar) + #if os(macOS) + .scrollContentBackground(.hidden) + .background(.clear) + .safeAreaInset(edge: .top) { + Color.clear.frame(height: 48) + } + .safeAreaInset(edge: .bottom) { + accountFooter + } + #endif + .refreshable { + await onRefreshMusicbills() + } + } + + private var accountRow: some View { + HStack(spacing: 10) { + SidebarAccountSummary(server: server, user: user) + + Spacer(minLength: 0) + + accountMenu + .labelStyle(.iconOnly) + } + .tag(PlayerContentSelection.profile) + .help(server.hostname) + } + + #if os(macOS) + private var accountFooter: some View { + HStack(spacing: 8) { + Button { + selectedContent = .profile + } label: { + SidebarAccountSummary(server: server, user: user) + } + .buttonStyle(.plain) + + Spacer(minLength: 0) + + accountMenu + .labelStyle(.iconOnly) + .menuStyle(.borderlessButton) + } + .padding(.horizontal, 10) + .padding(.vertical, 9) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(selectedContent == .profile ? Color.accentColor.opacity(0.14) : Color.clear) + } + .padding(.horizontal, 8) + .padding(.bottom, 8) + } + #endif + + @ViewBuilder + private var serverStatusSection: some View { + if versionWarning != nil || metadataError != nil { + Section("Server") { + if let versionWarning { + SidebarSettingsLabel( + title: versionWarning, + systemImage: "exclamationmark.triangle", + tint: .orange + ) + } + + if let metadataError { + SidebarSettingsLabel( + title: metadataError, + systemImage: "wifi.exclamationmark", + tint: .secondary + ) + + Button { + onRetryServerCheck() + } label: { + SidebarSettingsLabel( + title: "Retry Server Check", + systemImage: "arrow.clockwise", + tint: .blue + ) + } + .buttonStyle(.plain) + .disabled(isRefreshingMetadata) + } + } + } + } + + private var accountMenu: some View { + Menu { + Button { + selectedContent = .profile + } label: { + Label("Profile", systemImage: "person.crop.circle") + } + + Button { + selectedContent = .settings + } label: { + Label("Settings", systemImage: "gearshape") + } + + Divider() + + Button(action: onSwitchAccount) { + Label("Switch Account", systemImage: "person.2") + } + + Button(action: onChangeServer) { + Label("Change Server", systemImage: "network") + } + + Button(role: .destructive, action: onSignOut) { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + } + } label: { + Label("Account", systemImage: "ellipsis.circle") + } + .help("Account") + } +} + +struct SidebarAccountSummary: View { + let server: ServerRecord + let user: ServerUserRecord + + var body: some View { + HStack(spacing: 10) { + ArtworkView( + urlString: user.avatar, + systemImage: "person.crop.square", + size: 36 + ) + + VStack(alignment: .leading, spacing: 2) { + Text(user.nickname) + .font(.headline) + .lineLimit(1) + + Text(server.hostname) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .contentShape(Rectangle()) + } +} + +struct SidebarSettingsLabel: View { + let title: String + let systemImage: String + let tint: Color + + var body: some View { + HStack(spacing: 9) { + SidebarSettingsIcon(systemImage: systemImage, tint: tint) + + Text(title) + #if os(macOS) + .font(.system(size: 16, weight: .medium)) + #endif + .lineLimit(1) + + Spacer(minLength: 0) + } + #if os(macOS) + .padding(.vertical, 4) + #endif + .contentShape(Rectangle()) + } +} + +struct SidebarSettingsIcon: View { + let systemImage: String + let tint: Color + + var body: some View { + Image(systemName: systemImage) + #if os(macOS) + .font(.system(size: 21, weight: .regular)) + .frame(width: 28, height: 28) + #else + .font(.system(size: 13, weight: .semibold)) + .frame(width: 24, height: 24) + #endif + .foregroundStyle(tint) + } +} + +struct MusicbillActionRow: View { + let isRefreshing: Bool + let onCreate: () -> Void + let onRefresh: () -> Void + + var body: some View { + HStack { + Button(action: onCreate) { + Label("New Musicbill", systemImage: "plus") + } + .labelStyle(.iconOnly) + .help("New Musicbill") + + Button(action: onRefresh) { + if isRefreshing { + ProgressView() + } else { + Label("Reload Musicbill List", systemImage: "arrow.clockwise") + } + } + .disabled(isRefreshing) + .labelStyle(.iconOnly) + .help("Reload Musicbill List") + + Spacer() + } + .buttonStyle(.borderless) + } +} + +struct MusicbillSidebarRow: View { + let musicbill: MusicbillSummary + + var body: some View { + HStack(spacing: 12) { + ArtworkView( + urlString: musicbill.cover, + placeholderURLString: musicbill.coverThumbnail, + systemImage: "music.note.list", + size: 36 + ) + + VStack(alignment: .leading, spacing: 3) { + Text(musicbill.name) + .lineLimit(1) + Text(musicbill.owner.nickname) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } +} diff --git a/apps/apple/Cicada/Features/Player/PlayerView.swift b/apps/apple/Cicada/Features/Player/PlayerView.swift index a777892c8..2aac815fd 100644 --- a/apps/apple/Cicada/Features/Player/PlayerView.swift +++ b/apps/apple/Cicada/Features/Player/PlayerView.swift @@ -3,34 +3,19 @@ import SwiftUI struct PlayerView: View { @ObservedObject var serverStore: ServerSetupStore + @EnvironmentObject private var navigationStore: AppNavigationStore @StateObject private var playerStore = PlayerStore() + @State private var selectedContent: PlayerContentSelection? = .exploreSearch @State private var isShowingCreateMusicbill = false @State private var isShowingNowPlaying = false @State private var isShowingQueue = false - @State private var isShowingSearch = false - @State private var isShowingProfile = false - @State private var isShowingSharedMusicbillInvitations = false - @State private var isShowingPublicMusicbillCollections = false - @State private var isShowingExploration = false - @State private var isShowingRadio = false - @State private var isShowingSettings = false + @State private var isShowingMacSidebarOverlay = false @StateObject private var settings = AppSettingsStore.shared var body: some View { if let server = serverStore.selectedServer, let user = serverStore.selectedUser { - NavigationSplitView { - sidebar(server: server, user: user) - } detail: { - detail - } - .safeAreaInset(edge: .bottom) { - MiniPlayerView(audioPlayer: playerStore.audioPlayer) { - isShowingNowPlaying = true - } onQueue: { - isShowingQueue = true - } - } + playerLayout(server: server, user: user) .sheet(isPresented: $isShowingNowPlaying) { NowPlayingDetailView( playerStore: playerStore, @@ -50,38 +35,8 @@ struct PlayerView: View { await playerStore.createMusicbill(name: name) } } - .sheet(isPresented: $isShowingSearch) { - SearchMusicView(playerStore: playerStore) - } - .sheet(isPresented: $isShowingProfile) { - AccountProfileView( - playerStore: playerStore, - serverStore: serverStore - ) - } - .sheet(isPresented: $isShowingSharedMusicbillInvitations) { - SharedMusicbillInvitationView(playerStore: playerStore) - } - .sheet(isPresented: $isShowingPublicMusicbillCollections) { - PublicMusicbillCollectionView(playerStore: playerStore) - } - .sheet(isPresented: $isShowingExploration) { - ExplorationView(playerStore: playerStore) - } - .sheet(isPresented: $isShowingRadio) { - RadioView( - playerStore: playerStore, - audioPlayer: playerStore.audioPlayer - ) - } - .sheet(isPresented: $isShowingSettings) { - SettingsView( - settings: settings, - playerStore: playerStore, - offlineCacheManager: playerStore.offlineCacheManager - ) - } .task(id: authKey(server: server, user: user)) { + selectedContent = .exploreSearch playerStore.configure(server: server, user: user) await playerStore.loadMusicbillList() } @@ -120,6 +75,18 @@ struct PlayerView: View { playerStore.acknowledgeAuthorizationExpired() serverStore.removeSelectedUser(message: message) } + .onChange(of: playerStore.selectedMusicbillID) { _, _ in + isShowingMacSidebarOverlay = false + } + .onChange(of: selectedContent) { _, selection in + syncSelectedContent(selection) + } + .onChange(of: navigationStore.playerContentRequest) { _, request in + guard let request else { return } + // App menu commands route into the same content selection used by the sidebar. + selectedContent = request.selection + navigationStore.consume(request) + } } else { ContentUnavailableView( "No User Selected", @@ -129,4488 +96,133 @@ struct PlayerView: View { } } - private func sidebar(server: ServerRecord, user: ServerUserRecord) -> some View { - List(selection: $playerStore.selectedMusicbillID) { - if let warning = serverStore.selectedServerVersionWarning { - Section { - Label(warning, systemImage: "exclamationmark.triangle") - .font(.footnote) - .foregroundStyle(.orange) - } - } - - if let metadataError = serverStore.selectedServerMetadataError { - Section { - Label(metadataError, systemImage: "wifi.exclamationmark") - .font(.footnote) - .foregroundStyle(.secondary) - - Button { - Task { - await serverStore.refreshSelectedServerMetadata() - } - } label: { - Label("Retry Server Check", systemImage: "arrow.clockwise") - } - .disabled(serverStore.isRefreshingSelectedServerMetadata) - } - } - - if playerStore.isLoadingMusicbillList && playerStore.musicbills.isEmpty { - ProgressView() - } else if playerStore.musicbills.isEmpty { - Label("No musicbills", systemImage: "music.note.list") - .foregroundStyle(.secondary) - } - - ForEach(playerStore.musicbills) { musicbill in - MusicbillSidebarRow(musicbill: musicbill) - .tag(musicbill.id as String?) - } - } - .navigationTitle("Cicada") - .refreshable { - await playerStore.loadMusicbillList() - } - .toolbar { - ToolbarItemGroup(placement: .primaryAction) { - Button { - isShowingExploration = true - } label: { - Image(systemName: "sparkles") - } - .help("Explore") - - Button { - isShowingRadio = true - } label: { - Image(systemName: "dot.radiowaves.left.and.right") - } - .help("Radio") - - Button { - isShowingSearch = true - } label: { - Image(systemName: "magnifyingglass") - } - .help("Search Music") - - Button { - isShowingSharedMusicbillInvitations = true - } label: { - Image(systemName: "person.2") - } - .help("Shared Invitations") - - Button { - isShowingPublicMusicbillCollections = true - } label: { - Image(systemName: "star") - } - .help("Public Collections") - - Button { - isShowingCreateMusicbill = true - } label: { - Image(systemName: "plus") - } - .help("New Musicbill") - - accountMenu(server: server, user: user) - } - } - } - @ViewBuilder - private var detail: some View { - if let selectedMusicbillID = playerStore.selectedMusicbillID { - MusicbillDetailView( - playerStore: playerStore, - musicbillID: selectedMusicbillID - ) - } else { - ContentUnavailableView( - "No Musicbill", - systemImage: "music.note.list", - description: Text("Choose a musicbill from the sidebar.") - ) + private func playerLayout(server: ServerRecord, user: ServerUserRecord) -> some View { + #if os(macOS) + MacPlayerLayout(isSidebarPresented: $isShowingMacSidebarOverlay) { + sidebar(server: server, user: user) + } detail: { + detail } + #else + NavigationSplitView { + sidebar(server: server, user: user) + } detail: { + detail + } + .navigationSplitViewStyle(.prominentDetail) + #endif } - private func accountMenu(server: ServerRecord, user: ServerUserRecord) -> some View { - Menu { - Button { - isShowingProfile = true - } label: { - Label("Profile", systemImage: "person.crop.circle") - } - - Button { - isShowingSettings = true - } label: { - Label("Settings", systemImage: "gearshape") - } - - Divider() - - Button { + private func sidebar(server: ServerRecord, user: ServerUserRecord) -> some View { + PlayerSidebar( + server: server, + user: user, + versionWarning: serverStore.selectedServerVersionWarning, + metadataError: serverStore.selectedServerMetadataError, + isRefreshingMetadata: serverStore.isRefreshingSelectedServerMetadata, + isLoadingMusicbillList: playerStore.isLoadingMusicbillList, + musicbills: playerStore.musicbills, + selectedContent: $selectedContent, + onCreateMusicbill: { + isShowingMacSidebarOverlay = false + isShowingCreateMusicbill = true + }, + onRefreshMusicbills: { + await playerStore.loadMusicbillList() + }, + onRetryServerCheck: { + Task { + await serverStore.refreshSelectedServerMetadata() + } + }, + onSwitchAccount: { + isShowingMacSidebarOverlay = false playerStore.audioPlayer.stop() serverStore.clearSelectedUser() - } label: { - Label("Switch Account", systemImage: "person.2") - } - - Button { + }, + onChangeServer: { + isShowingMacSidebarOverlay = false playerStore.audioPlayer.stop() serverStore.showServerSetup() - } label: { - Label("Change Server", systemImage: "network") - } - - Button(role: .destructive) { + }, + onSignOut: { + isShowingMacSidebarOverlay = false Task { await playerStore.deleteCurrentSession() playerStore.audioPlayer.stop() serverStore.removeSelectedUser() } - } label: { - Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") - } - } label: { - Label(user.nickname, systemImage: "person.crop.circle") - } - .help(server.hostname) - } - - private func authKey(server: ServerRecord, user: ServerUserRecord) -> String { - "\(server.origin)|\(user.id)|\(user.token)" - } -} - -private struct MusicbillSidebarRow: View { - let musicbill: MusicbillSummary - - var body: some View { - HStack(spacing: 12) { - ArtworkView( - urlString: musicbill.cover, - systemImage: "music.note.list", - size: 36 - ) - - VStack(alignment: .leading, spacing: 3) { - Text(musicbill.name) - .lineLimit(1) - Text(musicbill.owner.nickname) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) } - } - } -} - -private struct MusicbillNameSheet: View { - let title: String - let submitTitle: String - let isSaving: Bool - let onSubmit: (String) async -> Bool - - @Environment(\.dismiss) private var dismiss - @State private var name: String - - init( - title: String, - initialName: String, - submitTitle: String, - isSaving: Bool, - onSubmit: @escaping (String) async -> Bool - ) { - self.title = title - self.submitTitle = submitTitle - self.isSaving = isSaving - self.onSubmit = onSubmit - _name = State(initialValue: initialName) - } - - private var trimmedName: String { - name.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private var canSubmit: Bool { - !trimmedName.isEmpty && trimmedName.count <= 64 && !isSaving + ) } - var body: some View { - NavigationStack { - Form { - Section { - TextField("Name", text: $name) - .onSubmit { - guard canSubmit else { return } - Task { - await submit() - } - } - - LabeledContent("Characters", value: "\(trimmedName.count)/64") - .font(.footnote) - .foregroundStyle(trimmedName.count > 64 ? Color.red : Color.secondary) - } - } - .navigationTitle(title) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - .disabled(isSaving) - } - - ToolbarItem(placement: .confirmationAction) { - Button { - Task { - await submit() - } - } label: { - if isSaving { - ProgressView() - } else { - Text(submitTitle) - } - } - .disabled(!canSubmit) + @ViewBuilder + private var detail: some View { + detailContent + .safeAreaInset(edge: .bottom) { + MiniPlayerView(audioPlayer: playerStore.audioPlayer) { + isShowingNowPlaying = true + } onQueue: { + isShowingQueue = true } } - } - } - - private func submit() async { - let didSave = await onSubmit(trimmedName) - if didSave { - dismiss() - } - } -} - -private struct UserDetailTarget: Identifiable { - let id: String -} - -private struct AccountProfileView: View { - @ObservedObject var playerStore: PlayerStore - @ObservedObject var serverStore: ServerSetupStore - - @Environment(\.dismiss) private var dismiss - @State private var nickname = "" - @State private var isShowingPasswordSheet = false - @State private var isShowingSessions = false - @State private var userForDetail: UserDetailTarget? - - private var user: ServerUserRecord? { - serverStore.selectedUser - } - - private var trimmedNickname: String { - nickname - .components(separatedBy: .whitespacesAndNewlines) - .filter { !$0.isEmpty } - .joined(separator: " ") - } - - private var canSaveNickname: Bool { - guard let user else { return false } - return !playerStore.isUpdatingProfile && - !trimmedNickname.isEmpty && - trimmedNickname.count <= 32 && - trimmedNickname != user.nickname } - var body: some View { - NavigationStack { - Group { - if let user { - Form { - Section { - HStack(spacing: 14) { - ArtworkView( - urlString: user.avatar, - systemImage: "person.crop.square", - size: 72 - ) - - VStack(alignment: .leading, spacing: 5) { - Text(user.nickname) - .font(.headline) - Text("@\(user.username)") - .foregroundStyle(.secondary) - Label( - user.twoFAEnabled ? "2FA Enabled" : "2FA Disabled", - systemImage: user.twoFAEnabled ? "lock.shield" : "lock.open" - ) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) - } - - Section("Nickname") { - TextField("Nickname", text: $nickname) - .onChange(of: nickname) { _, value in - if value.count > 32 { - nickname = String(value.prefix(32)) - } - } - .onSubmit { - guard canSaveNickname else { return } - Task { - await saveNickname() - } - } - - LabeledContent("Characters", value: "\(trimmedNickname.count)/32") - .font(.footnote) - .foregroundStyle(trimmedNickname.count > 32 ? Color.red : Color.secondary) - } - - Section("Account") { - LabeledContent("Username", value: user.username) - LabeledContent("Joined", value: formatCicadaTimestamp(user.joinTimestamp, date: .abbreviated, time: .omitted)) - } - - Section { - Button { - Task { - await saveNickname() - } - } label: { - if playerStore.isUpdatingProfile { - ProgressView() - } else { - Label("Save Nickname", systemImage: "checkmark") - } - } - .disabled(!canSaveNickname) - - Button { - isShowingPasswordSheet = true - } label: { - Label("Change Password", systemImage: "key") - } - .disabled(playerStore.isChangingPassword) - - Button { - isShowingSessions = true - } label: { - Label("Authorized Devices", systemImage: "desktopcomputer.and.macbook") - } - - Button { - userForDetail = UserDetailTarget(id: user.id) - } label: { - Label("Public Profile", systemImage: "person.text.rectangle") - } - } - } - } else { - ContentUnavailableView( - "No User Selected", - systemImage: "person.crop.circle.badge.exclamationmark", - description: Text("Sign in before editing profile.") - ) - } - } - .navigationTitle("Profile") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - dismiss() - } - } - - ToolbarItem(placement: .primaryAction) { - Button { - Task { - await refreshProfile() - } - } label: { - Image(systemName: "arrow.clockwise") - } - .disabled(playerStore.isUpdatingProfile) - .help("Refresh Profile") - } - } - } - .sheet(isPresented: $isShowingPasswordSheet) { - ChangePasswordSheet( + @ViewBuilder + private var detailContent: some View { + switch selectedContent ?? .exploreSearch { + case .exploreSearch: + SearchMusicView( playerStore: playerStore, - twoFAEnabled: user?.twoFAEnabled == true + showsDoneButton: false, + embedsInNavigationStack: false ) - } - .sheet(isPresented: $isShowingSessions) { - AuthorizedDevicesView(playerStore: playerStore) - } - .sheet(item: $userForDetail) { target in - UserProfileView( - userID: target.id, - playerStore: playerStore + case .settings: + SettingsView( + settings: settings, + playerStore: playerStore, + offlineCacheManager: playerStore.offlineCacheManager, + showsDoneButton: false, + embedsInNavigationStack: false ) - } - .task(id: user?.id) { - nickname = user?.nickname ?? "" - await refreshProfile() - } - .onChange(of: user?.nickname ?? "") { _, value in - if !playerStore.isUpdatingProfile { - nickname = value - } - } - } - - private func refreshProfile() async { - if let profile = await playerStore.refreshCurrentProfile() { - serverStore.updateSelectedUser(profile: profile) - nickname = profile.nickname - } - } - - private func saveNickname() async { - guard canSaveNickname else { return } - if let profile = await playerStore.updateNickname(nickname) { - serverStore.updateSelectedUser(profile: profile) - nickname = profile.nickname - } - } -} - -private struct ChangePasswordSheet: View { - @ObservedObject var playerStore: PlayerStore - let twoFAEnabled: Bool - - @Environment(\.dismiss) private var dismiss - @State private var credential = "" - @State private var newPassword = "" - @State private var confirmation = "" - - private var canSubmit: Bool { - !credential.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - newPassword.count >= 6 && - newPassword.count <= 32 && - newPassword == confirmation && - !playerStore.isChangingPassword - } - - var body: some View { - NavigationStack { - Form { - Section { - SecureField( - twoFAEnabled ? "Current Password or 2FA Token" : "Current Password", - text: $credential - ) - SecureField("New Password", text: $newPassword) - .onChange(of: newPassword) { _, value in - if value.count > 32 { - newPassword = String(value.prefix(32)) - } - } - SecureField("Confirm Password", text: $confirmation) - .onChange(of: confirmation) { _, value in - if value.count > 32 { - confirmation = String(value.prefix(32)) - } - } - } footer: { - Text("Password must be 6-32 characters.") - } - - if !confirmation.isEmpty && newPassword != confirmation { - Section { - Label("Passwords do not match.", systemImage: "exclamationmark.triangle") - .foregroundStyle(.orange) - } - } - } - .navigationTitle("Change Password") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - .disabled(playerStore.isChangingPassword) - } - - ToolbarItem(placement: .confirmationAction) { - Button { - Task { - await submit() - } - } label: { - if playerStore.isChangingPassword { - ProgressView() - } else { - Text("Save") - } - } - .disabled(!canSubmit) - } - } - } - } - - private func submit() async { - let didChange = await playerStore.changePassword( - credential: credential, - newPassword: newPassword - ) - if didChange { - dismiss() - } - } -} - -private struct AuthorizedDevicesView: View { - @ObservedObject var playerStore: PlayerStore - - @Environment(\.dismiss) private var dismiss - @State private var sessionForRename: AuthSession? - @State private var sessionForRevocation: AuthSession? - - var body: some View { - NavigationStack { - content - .navigationTitle("Authorized Devices") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - dismiss() - } - } - - ToolbarItem(placement: .primaryAction) { - Button { - Task { - await playerStore.loadSessions(force: true) - } - } label: { - Image(systemName: "arrow.clockwise") - } - .disabled(playerStore.isLoadingSessions) - .help("Refresh Devices") - } - } - } - .sheet(item: $sessionForRename) { session in - RenameDeviceSheet( + case .profile: + AccountProfileView( playerStore: playerStore, - session: session + serverStore: serverStore, + showsDoneButton: false, + embedsInNavigationStack: false + ) + case .sharedInvitations: + SharedMusicbillInvitationView( + playerStore: playerStore, + showsDoneButton: false, + embedsInNavigationStack: false + ) + case .publicCollections: + PublicMusicbillCollectionView( + playerStore: playerStore, + showsDoneButton: false, + embedsInNavigationStack: false + ) + case .musicbill(let selectedMusicbillID): + MusicbillDetailView( + playerStore: playerStore, + musicbillID: selectedMusicbillID ) - } - .confirmationDialog( - "Revoke Device?", - isPresented: Binding( - get: { sessionForRevocation != nil }, - set: { isPresented in - if !isPresented { - sessionForRevocation = nil - } - } - ), - titleVisibility: .visible - ) { - if let sessionForRevocation { - Button("Revoke", role: .destructive) { - Task { - let didRevoke = await playerStore.revokeSession(id: sessionForRevocation.id) - if didRevoke { - self.sessionForRevocation = nil - } - } - } - } - Button("Cancel", role: .cancel) { - sessionForRevocation = nil - } - } message: { - if let sessionForRevocation { - Text("Revoke \(displayDeviceName(sessionForRevocation)) from this account.") - } - } - .task { - await playerStore.loadSessions() } } - @ViewBuilder - private var content: some View { - if playerStore.isLoadingSessions && playerStore.sessions.isEmpty { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if !playerStore.hasLoadedSessions { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if playerStore.sessions.isEmpty { - ContentUnavailableView( - "No Authorized Devices", - systemImage: "desktopcomputer.and.macbook", - description: Text("Signed-in devices will appear here.") - ) - } else { - List { - Section { - ForEach(playerStore.sessions) { session in - AuthSessionRow( - session: session, - isUpdating: playerStore.updatingSessionIDs.contains(session.id) - ) { - sessionForRename = session - } onRevoke: { - sessionForRevocation = session - } - } - } - } - .refreshable { - await playerStore.loadSessions(force: true) - } - .overlay { - if playerStore.isLoadingSessions { - ProgressView() - .padding(16) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - } + private func syncSelectedContent(_ selection: PlayerContentSelection?) { + // Sidebar selections must keep the shared player store in sync because + // detail loading and playback actions still key off the selected musicbill. + if case .musicbill(let id) = selection { + playerStore.selectedMusicbillID = id } + isShowingMacSidebarOverlay = false } -} - -private struct AuthSessionRow: View { - let session: AuthSession - let isUpdating: Bool - let onRename: () -> Void - let onRevoke: () -> Void - - var body: some View { - HStack(spacing: 12) { - Image(systemName: session.current ? "checkmark.circle.fill" : "desktopcomputer") - .font(.title2) - .foregroundStyle(session.current ? Color.accentColor : Color.secondary) - .frame(width: 34, height: 34) - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - Text(displayDeviceName(session)) - .font(.headline) - .lineLimit(1) - if session.current { - Text("Current") - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.accentColor.opacity(0.14)) - .clipShape(Capsule()) - } - } - - Text("Last seen \(formatCicadaTimestamp(session.lastSeenTimestamp, date: .abbreviated, time: .shortened))") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - - Text("Expires \(formatCicadaTimestamp(session.inactiveExpireTimestamp, date: .abbreviated, time: .shortened))") - .font(.caption) - .foregroundStyle(.tertiary) - .lineLimit(1) - } - - Spacer() - Menu { - Button { - onRename() - } label: { - Label("Rename", systemImage: "pencil") - } - .disabled(isUpdating) - - if !session.current { - Button(role: .destructive) { - onRevoke() - } label: { - Label("Revoke", systemImage: "trash") - } - .disabled(isUpdating) - } - } label: { - if isUpdating { - ProgressView() - } else { - Image(systemName: "ellipsis.circle") - } - } - .disabled(isUpdating) - } - .padding(.vertical, 4) - } -} - -private struct RenameDeviceSheet: View { - @ObservedObject var playerStore: PlayerStore - let session: AuthSession - - @Environment(\.dismiss) private var dismiss - @State private var deviceName: String - - init(playerStore: PlayerStore, session: AuthSession) { - self.playerStore = playerStore - self.session = session - _deviceName = State(initialValue: displayDeviceName(session)) - } - - private var trimmedDeviceName: String { - deviceName.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private var isSaving: Bool { - playerStore.updatingSessionIDs.contains(session.id) - } - - private var canSubmit: Bool { - !trimmedDeviceName.isEmpty && - trimmedDeviceName.count <= 64 && - !isSaving - } - - var body: some View { - NavigationStack { - Form { - Section { - TextField("Device Name", text: $deviceName) - .onChange(of: deviceName) { _, value in - if value.count > 64 { - deviceName = String(value.prefix(64)) - } - } - .onSubmit { - guard canSubmit else { return } - Task { - await submit() - } - } - - LabeledContent("Characters", value: "\(trimmedDeviceName.count)/64") - .font(.footnote) - .foregroundStyle(trimmedDeviceName.count > 64 ? Color.red : Color.secondary) - } - } - .navigationTitle("Rename Device") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - .disabled(isSaving) - } - - ToolbarItem(placement: .confirmationAction) { - Button { - Task { - await submit() - } - } label: { - if isSaving { - ProgressView() - } else { - Text("Save") - } - } - .disabled(!canSubmit) - } - } - } - } - - private func submit() async { - let didRename = await playerStore.renameSession( - id: session.id, - deviceName: trimmedDeviceName - ) - if didRename { - dismiss() - } - } -} - -private struct UserProfileView: View { - let userID: UserDetail.ID - @ObservedObject var playerStore: PlayerStore - - @Environment(\.dismiss) private var dismiss - @State private var publicMusicbillForDetail: PublicMusicbillSearchItem? - - private var detail: UserDetail? { - playerStore.userDetails[userID] - } - - var body: some View { - NavigationStack { - Group { - if let detail { - userContent(detail) - } else if playerStore.loadingUserIDs.contains(userID) { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ContentUnavailableView( - "User Not Loaded", - systemImage: "person.crop.circle.badge.exclamationmark", - description: Text("Pull to refresh or try again.") - ) - } - } - .navigationTitle(detail?.nickname ?? "User") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - dismiss() - } - } - } - } - .sheet(item: $publicMusicbillForDetail) { musicbill in - PublicMusicbillDetailView( - musicbill: musicbill, - playerStore: playerStore - ) - } - .task(id: userID) { - await playerStore.loadUser(id: userID) - } - } - - private func userContent(_ detail: UserDetail) -> some View { - List { - Section { - HStack(spacing: 14) { - ArtworkView( - urlString: detail.avatar, - systemImage: "person.crop.square", - size: 76 - ) - - VStack(alignment: .leading, spacing: 5) { - Text(detail.nickname) - .font(.headline) - .lineLimit(1) - Text("@\(detail.username)") - .foregroundStyle(.secondary) - .lineLimit(1) - Text("Joined \(formatCicadaTimestamp(detail.joinTimestamp, date: .abbreviated, time: .omitted))") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) - } - - Section("Public Musicbills") { - if detail.musicbillList.isEmpty { - ContentUnavailableView( - "No Public Musicbills", - systemImage: "music.note.list", - description: Text("This user has not published musicbills.") - ) - } else { - ForEach(detail.musicbillList) { musicbill in - UserPublicMusicbillRow(musicbill: musicbill) { - publicMusicbillForDetail = musicbill.searchItem(user: detail.musicbillUser) - } - } - } - } - } - .refreshable { - await playerStore.loadUser(id: detail.id, force: true) - } - } -} - -private struct UserPublicMusicbillRow: View { - let musicbill: UserPublicMusicbill - let onOpen: () -> Void - - var body: some View { - Button(action: onOpen) { - HStack(spacing: 12) { - ArtworkView( - urlString: musicbill.cover, - systemImage: "music.note.list", - size: 50 - ) - - VStack(alignment: .leading, spacing: 4) { - Text(musicbill.name) - .font(.headline) - .lineLimit(1) - Text("\(musicbill.musicCount) songs") - .font(.footnote) - .foregroundStyle(.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.tertiary) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } -} - -private enum MusicbillCaptchaAction: Identifiable, Equatable { - case unpublish(id: MusicbillDetail.ID, name: String) - case delete(id: MusicbillDetail.ID, name: String) - - var id: String { - switch self { - case .unpublish(let id, _): - return "unpublish-\(id)" - case .delete(let id, _): - return "delete-\(id)" - } - } - - var musicbillID: MusicbillDetail.ID { - switch self { - case .unpublish(let id, _), .delete(let id, _): - return id - } - } - - var title: String { - switch self { - case .unpublish: - return "Make Private" - case .delete: - return "Delete Musicbill" - } - } - - var confirmTitle: String { - switch self { - case .unpublish: - return "Make Private" - case .delete: - return "Delete" - } - } - - var message: String { - switch self { - case .unpublish(_, let name): - return "\"\(name)\" will become private. Existing public collections for this musicbill will be removed." - case .delete(_, let name): - return "\"\(name)\" will be deleted. This cannot be undone." - } - } - - var systemImage: String { - switch self { - case .unpublish: - return "lock" - case .delete: - return "trash" - } - } -} - -private struct MusicbillCaptchaActionSheet: View { - let action: MusicbillCaptchaAction - @ObservedObject var playerStore: PlayerStore - - @Environment(\.dismiss) private var dismiss - @State private var captchaValue = "" - - private var trimmedCaptchaValue: String { - captchaValue.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private var canSubmit: Bool { - playerStore.musicbillActionCaptcha != nil && - !trimmedCaptchaValue.isEmpty && - !playerStore.isSavingMusicbill && - !playerStore.isLoadingMusicbillActionCaptcha - } - - var body: some View { - NavigationStack { - Form { - Section { - Label(action.message, systemImage: action.systemImage) - .foregroundStyle(.secondary) - } - - Section("Captcha") { - captchaBlock - - TextField("Captcha", text: $captchaValue) - #if os(iOS) - .textInputAutocapitalization(.never) - #endif - .autocorrectionDisabled() - .onSubmit { - guard canSubmit else { return } - Task { - await submit() - } - } - } - } - .navigationTitle(action.title) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - .disabled(playerStore.isSavingMusicbill) - } - - ToolbarItem(placement: .confirmationAction) { - Button(role: .destructive) { - Task { - await submit() - } - } label: { - if playerStore.isSavingMusicbill { - ProgressView() - } else { - Text(action.confirmTitle) - } - } - .disabled(!canSubmit) - } - } - } - .task(id: action.id) { - captchaValue = "" - await playerStore.loadMusicbillActionCaptcha(force: true) - } - .onDisappear { - if !playerStore.isSavingMusicbill { - playerStore.clearMusicbillActionCaptcha() - } - } - } - - private var captchaBlock: some View { - HStack { - Group { - if let captcha = playerStore.musicbillActionCaptcha { - CaptchaImageView(svg: captcha.svg) - } else if playerStore.isLoadingMusicbillActionCaptcha { - ProgressView() - } else { - Image(systemName: "checkmark.shield") - .foregroundStyle(.secondary) - } - } - .frame(width: 160, height: 54) - .background(Color.cicadaSecondaryBackground) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - - Button { - Task { - captchaValue = "" - await playerStore.loadMusicbillActionCaptcha(force: true) - } - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .disabled(playerStore.isLoadingMusicbillActionCaptcha || playerStore.isSavingMusicbill) - } - } - - private func submit() async { - let didSave: Bool - switch action { - case .unpublish(let id, _): - didSave = await playerStore.unpublishMusicbill( - id: id, - captchaValue: trimmedCaptchaValue - ) - case .delete(let id, _): - didSave = await playerStore.deleteMusicbill( - id: id, - captchaValue: trimmedCaptchaValue - ) - } - - if didSave { - dismiss() - } else { - captchaValue = "" - await playerStore.loadMusicbillActionCaptcha(force: true) - } - } -} - -private struct AddToMusicbillSheet: View { - let music: Music - @ObservedObject var playerStore: PlayerStore - - @Environment(\.dismiss) private var dismiss - @State private var workingMusicbillID: MusicbillSummary.ID? - @State private var isShowingCreateMusicbill = false - - var body: some View { - NavigationStack { - Group { - if playerStore.musicbills.isEmpty { - ContentUnavailableView( - "No Musicbills", - systemImage: "music.note.list", - description: Text("Create a musicbill before adding songs.") - ) - } else { - List { - Section { - ForEach(playerStore.musicbills) { musicbill in - AddToMusicbillRow( - musicbill: musicbill, - containsMusic: playerStore.containsMusic(music.id, in: musicbill.id), - isLoading: playerStore.loadingMusicbillIDs.contains(musicbill.id), - isWorking: workingMusicbillID == musicbill.id - ) { - await toggleMusic(in: musicbill) - } - .task(id: musicbill.id) { - await playerStore.loadMusicbill(id: musicbill.id) - } - } - } footer: { - Text("Tap a musicbill to add or remove this song.") - } - } - } - } - .navigationTitle("Add to Musicbill") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - dismiss() - } - } - - ToolbarItem(placement: .primaryAction) { - Button { - isShowingCreateMusicbill = true - } label: { - Image(systemName: "plus") - } - .disabled(playerStore.isSavingMusicbill) - .help("New Musicbill") - } - } - } - .sheet(isPresented: $isShowingCreateMusicbill) { - MusicbillNameSheet( - title: "New Musicbill", - initialName: "", - submitTitle: "Create", - isSaving: playerStore.isSavingMusicbill - ) { name in - await playerStore.createMusicbill(name: name) - } - } - } - - private func toggleMusic(in musicbill: MusicbillSummary) async { - guard workingMusicbillID == nil, !playerStore.isSavingMusicbill else { return } - workingMusicbillID = musicbill.id - defer { workingMusicbillID = nil } - - if playerStore.containsMusic(music.id, in: musicbill.id) == nil { - await playerStore.loadMusicbill(id: musicbill.id, force: true) - } - - if playerStore.containsMusic(music.id, in: musicbill.id) == true { - _ = await playerStore.removeMusic(music, from: musicbill.id) - } else { - _ = await playerStore.addMusic(music, to: musicbill.id) - } - } -} - -private struct AddToMusicbillRow: View { - let musicbill: MusicbillSummary - let containsMusic: Bool? - let isLoading: Bool - let isWorking: Bool - let onToggle: () async -> Void - - private var isDisabled: Bool { - isWorking || (isLoading && containsMusic == nil) - } - - var body: some View { - Button { - Task { - await onToggle() - } - } label: { - HStack(spacing: 12) { - statusIcon - - ArtworkView( - urlString: musicbill.cover, - systemImage: "music.note.list", - size: 42 - ) - - VStack(alignment: .leading, spacing: 3) { - Text(musicbill.name) - .lineLimit(1) - - HStack(spacing: 6) { - Text(musicbill.owner.nickname) - .lineLimit(1) - - if musicbill.isPublic { - Label("Public", systemImage: "globe") - .labelStyle(.titleAndIcon) - .lineLimit(1) - } - } - .font(.footnote) - .foregroundStyle(.secondary) - } - - Spacer() - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(isDisabled) - } - - @ViewBuilder - private var statusIcon: some View { - if isWorking { - ProgressView() - .frame(width: 24, height: 24) - } else if containsMusic == true { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.tint) - .font(.title3) - .frame(width: 24, height: 24) - } else if isLoading { - ProgressView() - .frame(width: 24, height: 24) - } else { - Image(systemName: "circle") - .foregroundStyle(.secondary) - .font(.title3) - .frame(width: 24, height: 24) - } - } -} - -private struct SharedMusicbillInvitationView: View { - @ObservedObject var playerStore: PlayerStore - - @Environment(\.dismiss) private var dismiss - @State private var userForDetail: UserDetailTarget? - - var body: some View { - NavigationStack { - content - .navigationTitle("Shared Invitations") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - dismiss() - } - } - - ToolbarItem(placement: .primaryAction) { - Button { - Task { - await playerStore.loadSharedMusicbillInvitations(force: true) - } - } label: { - Image(systemName: "arrow.clockwise") - } - .disabled(playerStore.isLoadingSharedMusicbillInvitations) - .help("Refresh Invitations") - } - } - } - .task { - await playerStore.loadSharedMusicbillInvitations(force: true) - } - .sheet(item: $userForDetail) { target in - UserProfileView( - userID: target.id, - playerStore: playerStore - ) - } - } - - @ViewBuilder - private var content: some View { - if playerStore.isLoadingSharedMusicbillInvitations && - playerStore.sharedMusicbillInvitations.isEmpty { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if !playerStore.hasLoadedSharedMusicbillInvitations { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if playerStore.sharedMusicbillInvitations.isEmpty { - ContentUnavailableView( - "No Invitations", - systemImage: "person.2", - description: Text("Shared musicbill invitations will appear here.") - ) - } else { - List { - Section { - Label( - "Invitations expire automatically after 3-4 days.", - systemImage: "clock" - ) - .font(.footnote) - .foregroundStyle(.secondary) - } - - Section("Invitations") { - ForEach(playerStore.sharedMusicbillInvitations) { invitation in - SharedMusicbillInvitationRow( - invitation: invitation, - isAccepting: playerStore.acceptingSharedMusicbillInvitationIDs.contains(invitation.id) - ) { - userForDetail = UserDetailTarget(id: invitation.inviteUserID) - } onAccept: { - let didAccept = await playerStore.acceptSharedMusicbillInvitation(invitation) - if didAccept { - dismiss() - } - } - } - } - } - .refreshable { - await playerStore.loadSharedMusicbillInvitations(force: true) - } - .overlay { - if playerStore.isLoadingSharedMusicbillInvitations { - ProgressView() - .padding(16) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - } - } - } -} - -private struct SharedMusicbillInvitationRow: View { - let invitation: SharedMusicbillInvitation - let isAccepting: Bool - let onOpenUser: () -> Void - let onAccept: () async -> Void - - private var musicbillName: String { - invitation.musicbillName.isEmpty ? "Musicbill" : invitation.musicbillName - } - - private var inviteDate: Date { - let timestamp = invitation.inviteTimestamp - let seconds = timestamp > 10_000_000_000 ? timestamp / 1000 : timestamp - return Date(timeIntervalSince1970: seconds) - } - - var body: some View { - HStack(spacing: 12) { - Image(systemName: "person.crop.circle.badge.plus") - .font(.title2) - .foregroundStyle(.tint) - .frame(width: 34, height: 34) - - VStack(alignment: .leading, spacing: 4) { - Button(action: onOpenUser) { - Label(invitation.inviteUserNickname, systemImage: "person.crop.circle") - .font(.headline) - .lineLimit(1) - } - .buttonStyle(.plain) - - Text(musicbillName) - .foregroundStyle(.secondary) - .lineLimit(1) - - Text(inviteDate.formatted(date: .abbreviated, time: .shortened)) - .font(.caption) - .foregroundStyle(.tertiary) - } - - Spacer() - - Button { - Task { - await onAccept() - } - } label: { - if isAccepting { - ProgressView() - } else { - Label("Accept", systemImage: "checkmark.circle") - } - } - .disabled(isAccepting) - .buttonStyle(.borderedProminent) - } - .padding(.vertical, 4) - } -} - -private struct PublicMusicbillCollectionView: View { - @ObservedObject var playerStore: PlayerStore - - @Environment(\.dismiss) private var dismiss - @State private var searchText = "" - @State private var musicbillForDetail: PublicMusicbillSearchItem? - - var body: some View { - NavigationStack { - content - .navigationTitle("Public Collections") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - dismiss() - } - } - - ToolbarItem(placement: .primaryAction) { - Button { - Task { - await load(page: 1, force: true) - } - } label: { - Image(systemName: "magnifyingglass") - } - .disabled(playerStore.isLoadingPublicMusicbillCollections) - .help("Search Collections") - } - } - } - .searchable(text: $searchText, prompt: "Collections") - .onSubmit(of: .search) { - Task { - await load(page: 1, force: true) - } - } - .sheet(item: $musicbillForDetail) { musicbill in - PublicMusicbillDetailView( - musicbill: musicbill, - playerStore: playerStore - ) - } - .task { - searchText = playerStore.publicMusicbillCollectionKeyword - if !playerStore.hasLoadedPublicMusicbillCollections { - await load(page: 1) - } - } - } - - @ViewBuilder - private var content: some View { - if playerStore.isLoadingPublicMusicbillCollections && - playerStore.publicMusicbillCollections.isEmpty { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if !playerStore.hasLoadedPublicMusicbillCollections { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if playerStore.publicMusicbillCollections.isEmpty { - ContentUnavailableView( - playerStore.publicMusicbillCollectionKeyword.isEmpty ? "No Collections" : "No Results", - systemImage: "star", - description: Text( - playerStore.publicMusicbillCollectionKeyword.isEmpty ? - "Collect public musicbills from Search." : - "Try another collection keyword." - ) - ) - } else { - List { - Section("\(playerStore.publicMusicbillCollectionTotal) Musicbills") { - ForEach(playerStore.publicMusicbillCollections) { collection in - PublicMusicbillSearchRow(musicbill: collection.searchItem) { - musicbillForDetail = collection.searchItem - } - } - } - - if totalPages > 1 { - Section { - HStack { - Button { - Task { - await load(page: playerStore.publicMusicbillCollectionPage - 1) - } - } label: { - Label("Previous", systemImage: "chevron.left") - } - .disabled( - playerStore.publicMusicbillCollectionPage <= 1 || - playerStore.isLoadingPublicMusicbillCollections - ) - - Spacer() - - Text("Page \(playerStore.publicMusicbillCollectionPage) of \(totalPages)") - .font(.footnote.monospacedDigit()) - .foregroundStyle(.secondary) - - Spacer() - - Button { - Task { - await load(page: playerStore.publicMusicbillCollectionPage + 1) - } - } label: { - Label("Next", systemImage: "chevron.right") - } - .disabled( - playerStore.publicMusicbillCollectionPage >= totalPages || - playerStore.isLoadingPublicMusicbillCollections - ) - } - } - } - } - .refreshable { - await load(page: playerStore.publicMusicbillCollectionPage, force: true) - } - .overlay { - if playerStore.isLoadingPublicMusicbillCollections { - ProgressView() - .padding(16) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - } - } - } - - private var totalPages: Int { - max( - 1, - Int( - ceil( - Double(playerStore.publicMusicbillCollectionTotal) / - Double(playerStore.publicMusicbillCollectionPageSize) - ) - ) - ) - } - - private func load(page: Int, force: Bool = false) async { - await playerStore.loadPublicMusicbillCollections( - keyword: searchText, - page: page, - force: force - ) - searchText = playerStore.publicMusicbillCollectionKeyword - } -} - -private enum PlayerSearchTab: String, CaseIterable, Identifiable { - case music = "Music" - case artists = "Artists" - case publicMusicbills = "Public" - case lyrics = "Lyrics" - - var id: String { - rawValue - } - - var prompt: String { - switch self { - case .music, .artists, .lyrics: - return rawValue - case .publicMusicbills: - return "Public Musicbills" - } - } -} - -private struct SearchMusicView: View { - @ObservedObject var playerStore: PlayerStore - - @Environment(\.dismiss) private var dismiss - @State private var selectedTab = PlayerSearchTab.music - @State private var searchText = "" - @State private var musicForMusicbillSelection: Music? - @State private var artistForDetail: ArtistSearchItem? - @State private var publicMusicbillForDetail: PublicMusicbillSearchItem? - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - Picker("Search Type", selection: $selectedTab) { - ForEach(PlayerSearchTab.allCases) { tab in - Text(tab.rawValue).tag(tab) - } - } - .pickerStyle(.segmented) - .padding() - - content - } - .navigationTitle("Search") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - dismiss() - } - } - - ToolbarItem(placement: .primaryAction) { - Button { - Task { - await search(page: 1) - } - } label: { - Image(systemName: "magnifyingglass") - } - .disabled(trimmedSearchText.isEmpty || isSearching) - .help("Search") - } - } - } - .searchable(text: $searchText, prompt: selectedTab.prompt) - .onSubmit(of: .search) { - Task { - await search(page: 1) - } - } - .onChange(of: selectedTab) { _, _ in - syncSearchTextWithSelectedTab() - } - .sheet(item: $musicForMusicbillSelection) { music in - AddToMusicbillSheet( - music: music, - playerStore: playerStore - ) - } - .sheet(item: $artistForDetail) { artist in - ArtistDetailView( - artist: artist, - playerStore: playerStore - ) - } - .sheet(item: $publicMusicbillForDetail) { musicbill in - PublicMusicbillDetailView( - musicbill: musicbill, - playerStore: playerStore - ) - } - } - - @ViewBuilder - private var content: some View { - switch selectedTab { - case .music: - musicContent - case .artists: - artistContent - case .publicMusicbills: - publicMusicbillContent - case .lyrics: - lyricContent - } - } - - @ViewBuilder - private var musicContent: some View { - if playerStore.isSearchingMusic && playerStore.searchMusicResults.isEmpty { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if !playerStore.hasSearchedMusic { - ContentUnavailableView( - "Search Music", - systemImage: "magnifyingglass", - description: Text("Search by song, alias, artist, lyricist, or composer.") - ) - } else if playerStore.searchMusicResults.isEmpty { - ContentUnavailableView( - "No Results", - systemImage: "music.note", - description: Text("Try another keyword.") - ) - } else { - List { - Section("\(playerStore.searchMusicTotal) Songs") { - ForEach(playerStore.searchMusicResults) { music in - MusicRow( - music: music, - isCurrent: playerStore.audioPlayer.currentMusic?.id == music.id, - isPlaying: playerStore.audioPlayer.currentMusic?.id == music.id && playerStore.audioPlayer.isPlaying - ) { - playerStore.play(music: music, in: playerStore.searchMusicResults) - } onAddToMusicbill: { - musicForMusicbillSelection = music - } onSaveOffline: { - playerStore.saveOffline(music) - } - } - } - - if musicTotalPages > 1 { - Section { - HStack { - Button { - Task { - await search(page: playerStore.searchMusicPage - 1) - } - } label: { - Label("Previous", systemImage: "chevron.left") - } - .disabled(playerStore.searchMusicPage <= 1 || playerStore.isSearchingMusic) - - Spacer() - - Text("Page \(playerStore.searchMusicPage) of \(musicTotalPages)") - .font(.footnote.monospacedDigit()) - .foregroundStyle(.secondary) - - Spacer() - - Button { - Task { - await search(page: playerStore.searchMusicPage + 1) - } - } label: { - Label("Next", systemImage: "chevron.right") - } - .disabled(playerStore.searchMusicPage >= musicTotalPages || playerStore.isSearchingMusic) - } - } - } - } - .overlay { - if playerStore.isSearchingMusic { - ProgressView() - .padding(16) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - } - } - } - - @ViewBuilder - private var artistContent: some View { - if playerStore.isSearchingArtists && playerStore.searchArtistResults.isEmpty { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if !playerStore.hasSearchedArtists { - ContentUnavailableView( - "Search Artists", - systemImage: "person.2", - description: Text("Search by artist name or alias.") - ) - } else if playerStore.searchArtistResults.isEmpty { - ContentUnavailableView( - "No Results", - systemImage: "person.crop.circle", - description: Text("Try another artist keyword.") - ) - } else { - List { - Section("\(playerStore.searchArtistTotal) Artists") { - ForEach(playerStore.searchArtistResults) { artist in - ArtistSearchRow(artist: artist) { - artistForDetail = artist - } - } - } - - if artistTotalPages > 1 { - Section { - HStack { - Button { - Task { - await search(page: playerStore.searchArtistPage - 1) - } - } label: { - Label("Previous", systemImage: "chevron.left") - } - .disabled(playerStore.searchArtistPage <= 1 || playerStore.isSearchingArtists) - - Spacer() - - Text("Page \(playerStore.searchArtistPage) of \(artistTotalPages)") - .font(.footnote.monospacedDigit()) - .foregroundStyle(.secondary) - - Spacer() - - Button { - Task { - await search(page: playerStore.searchArtistPage + 1) - } - } label: { - Label("Next", systemImage: "chevron.right") - } - .disabled(playerStore.searchArtistPage >= artistTotalPages || playerStore.isSearchingArtists) - } - } - } - } - .overlay { - if playerStore.isSearchingArtists { - ProgressView() - .padding(16) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - } - } - } - - @ViewBuilder - private var publicMusicbillContent: some View { - if playerStore.isSearchingPublicMusicbills && playerStore.searchPublicMusicbillResults.isEmpty { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if !playerStore.hasSearchedPublicMusicbills { - ContentUnavailableView( - "Search Public Musicbills", - systemImage: "music.note.list", - description: Text("Search public musicbills by name.") - ) - } else if playerStore.searchPublicMusicbillResults.isEmpty { - ContentUnavailableView( - "No Results", - systemImage: "music.note.list", - description: Text("Try another musicbill keyword.") - ) - } else { - List { - Section("\(playerStore.searchPublicMusicbillTotal) Musicbills") { - ForEach(playerStore.searchPublicMusicbillResults) { musicbill in - PublicMusicbillSearchRow(musicbill: musicbill) { - publicMusicbillForDetail = musicbill - } - } - } - - if publicMusicbillTotalPages > 1 { - Section { - HStack { - Button { - Task { - await search(page: playerStore.searchPublicMusicbillPage - 1) - } - } label: { - Label("Previous", systemImage: "chevron.left") - } - .disabled(playerStore.searchPublicMusicbillPage <= 1 || playerStore.isSearchingPublicMusicbills) - - Spacer() - - Text("Page \(playerStore.searchPublicMusicbillPage) of \(publicMusicbillTotalPages)") - .font(.footnote.monospacedDigit()) - .foregroundStyle(.secondary) - - Spacer() - - Button { - Task { - await search(page: playerStore.searchPublicMusicbillPage + 1) - } - } label: { - Label("Next", systemImage: "chevron.right") - } - .disabled(playerStore.searchPublicMusicbillPage >= publicMusicbillTotalPages || playerStore.isSearchingPublicMusicbills) - } - } - } - } - .overlay { - if playerStore.isSearchingPublicMusicbills { - ProgressView() - .padding(16) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - } - } - } - - @ViewBuilder - private var lyricContent: some View { - if playerStore.isSearchingLyrics && playerStore.searchLyricResults.isEmpty { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if !playerStore.hasSearchedLyrics { - ContentUnavailableView( - "Search Lyrics", - systemImage: "text.quote", - description: Text("Search matching lyric lines across songs.") - ) - } else if playerStore.searchLyricResults.isEmpty { - ContentUnavailableView( - "No Results", - systemImage: "text.quote", - description: Text("Try another lyric keyword.") - ) - } else { - List { - Section("\(playerStore.searchLyricTotal) Songs") { - ForEach(playerStore.searchLyricResults) { result in - MusicRow( - music: result.music, - isCurrent: playerStore.audioPlayer.currentMusic?.id == result.music.id, - isPlaying: playerStore.audioPlayer.currentMusic?.id == result.music.id && playerStore.audioPlayer.isPlaying, - lyricSnippet: result.snippetLines, - lyricKeyword: playerStore.searchLyricKeyword - ) { - playerStore.play( - music: result.music, - in: playerStore.searchLyricResults.map(\.music) - ) - } onAddToMusicbill: { - musicForMusicbillSelection = result.music - } onSaveOffline: { - playerStore.saveOffline(result.music) - } - } - } - - if lyricTotalPages > 1 { - Section { - HStack { - Button { - Task { - await search(page: playerStore.searchLyricPage - 1) - } - } label: { - Label("Previous", systemImage: "chevron.left") - } - .disabled(playerStore.searchLyricPage <= 1 || playerStore.isSearchingLyrics) - - Spacer() - - Text("Page \(playerStore.searchLyricPage) of \(lyricTotalPages)") - .font(.footnote.monospacedDigit()) - .foregroundStyle(.secondary) - - Spacer() - - Button { - Task { - await search(page: playerStore.searchLyricPage + 1) - } - } label: { - Label("Next", systemImage: "chevron.right") - } - .disabled(playerStore.searchLyricPage >= lyricTotalPages || playerStore.isSearchingLyrics) - } - } - } - } - .overlay { - if playerStore.isSearchingLyrics { - ProgressView() - .padding(16) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - } - } - } - - private var trimmedSearchText: String { - searchText.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private var isSearching: Bool { - switch selectedTab { - case .music: - return playerStore.isSearchingMusic - case .artists: - return playerStore.isSearchingArtists - case .publicMusicbills: - return playerStore.isSearchingPublicMusicbills - case .lyrics: - return playerStore.isSearchingLyrics - } - } - - private var musicTotalPages: Int { - totalPages(total: playerStore.searchMusicTotal, pageSize: playerStore.searchMusicPageSize) - } - - private var artistTotalPages: Int { - totalPages(total: playerStore.searchArtistTotal, pageSize: playerStore.searchArtistPageSize) - } - - private var publicMusicbillTotalPages: Int { - totalPages(total: playerStore.searchPublicMusicbillTotal, pageSize: playerStore.searchPublicMusicbillPageSize) - } - - private var lyricTotalPages: Int { - totalPages(total: playerStore.searchLyricTotal, pageSize: playerStore.searchLyricPageSize) - } - - private func search(page: Int) async { - switch selectedTab { - case .music: - await playerStore.searchMusic(keyword: searchText, page: page) - if !playerStore.searchMusicKeyword.isEmpty { - searchText = playerStore.searchMusicKeyword - } - case .artists: - await playerStore.searchArtists(keyword: searchText, page: page) - if !playerStore.searchArtistKeyword.isEmpty { - searchText = playerStore.searchArtistKeyword - } - case .publicMusicbills: - await playerStore.searchPublicMusicbills(keyword: searchText, page: page) - if !playerStore.searchPublicMusicbillKeyword.isEmpty { - searchText = playerStore.searchPublicMusicbillKeyword - } - case .lyrics: - await playerStore.searchLyrics(keyword: searchText, page: page) - if !playerStore.searchLyricKeyword.isEmpty { - searchText = playerStore.searchLyricKeyword - } - } - } - - private func syncSearchTextWithSelectedTab() { - switch selectedTab { - case .music: - if !playerStore.searchMusicKeyword.isEmpty { - searchText = playerStore.searchMusicKeyword - } - case .artists: - if !playerStore.searchArtistKeyword.isEmpty { - searchText = playerStore.searchArtistKeyword - } - case .publicMusicbills: - if !playerStore.searchPublicMusicbillKeyword.isEmpty { - searchText = playerStore.searchPublicMusicbillKeyword - } - case .lyrics: - if !playerStore.searchLyricKeyword.isEmpty { - searchText = playerStore.searchLyricKeyword - } - } - } - - private func totalPages(total: Int, pageSize: Int) -> Int { - max(1, Int(ceil(Double(total) / Double(pageSize)))) - } -} - -private struct ArtistSearchRow: View { - let artist: ArtistSearchItem - let onOpen: () -> Void - - var body: some View { - Button(action: onOpen) { - HStack(spacing: 12) { - ArtworkView( - urlString: artist.avatar, - systemImage: "person.crop.square", - size: 50 - ) - - VStack(alignment: .leading, spacing: 4) { - Text(artist.name) - .font(.headline) - .lineLimit(1) - - if !artist.aliases.isEmpty { - Text(artist.aliases.joined(separator: " / ")) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - - Spacer() - - VStack(alignment: .trailing, spacing: 2) { - Text("\(artist.musicCount)") - .font(.headline.monospacedDigit()) - Text("Songs") - .font(.caption) - .foregroundStyle(.secondary) - } - - Image(systemName: "chevron.right") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.tertiary) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } -} - -private enum ArtistMusicRole: String, CaseIterable, Identifiable { - case performer = "Performed" - case lyricist = "Lyrics" - case composer = "Composed" - - var id: String { - rawValue - } -} - -private struct ArtistDetailView: View { - let artist: ArtistSearchItem - @ObservedObject var playerStore: PlayerStore - - @Environment(\.dismiss) private var dismiss - @State private var selectedRole = ArtistMusicRole.performer - @State private var musicForMusicbillSelection: Music? - - private var detail: ArtistDetail? { - playerStore.artistDetails[artist.id] - } - - var body: some View { - NavigationStack { - Group { - if let detail { - detailContent(detail) - } else if playerStore.loadingArtistIDs.contains(artist.id) { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ContentUnavailableView( - "Artist Not Loaded", - systemImage: "person.crop.circle.badge.exclamationmark", - description: Text("Pull to refresh or try again.") - ) - } - } - .navigationTitle(detail?.name ?? artist.name) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - dismiss() - } - } - } - } - .sheet(item: $musicForMusicbillSelection) { music in - AddToMusicbillSheet( - music: music, - playerStore: playerStore - ) - } - .task(id: artist.id) { - await playerStore.loadArtist(id: artist.id) - } - } - - private func detailContent(_ detail: ArtistDetail) -> some View { - let roles = availableRoles(for: detail) - let role = roles.contains(selectedRole) ? selectedRole : roles.first ?? .performer - let musicList = musicList(for: role, in: detail) - - return List { - Section { - HStack(spacing: 14) { - ArtworkView( - urlString: detail.avatar.isEmpty ? artist.avatar : detail.avatar, - systemImage: "person.crop.square", - size: 72 - ) - - VStack(alignment: .leading, spacing: 5) { - Text(detail.name) - .font(.headline) - - if !detail.aliases.isEmpty { - Text(detail.aliases.joined(separator: " / ")) - .foregroundStyle(.secondary) - .lineLimit(2) - } - - Text("\(uniqueMusicList(in: detail).count) songs") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) - } - - if roles.count > 1 { - Section { - Picker("Music Role", selection: $selectedRole) { - ForEach(roles) { role in - Text(role.rawValue).tag(role) - } - } - .pickerStyle(.segmented) - } - } - - Section(role.rawValue) { - if musicList.isEmpty { - ContentUnavailableView( - "No Songs", - systemImage: "music.note", - description: Text("This artist has no songs in this category.") - ) - } else { - ForEach(musicList) { music in - MusicRow( - music: music, - isCurrent: playerStore.audioPlayer.currentMusic?.id == music.id, - isPlaying: playerStore.audioPlayer.currentMusic?.id == music.id && playerStore.audioPlayer.isPlaying - ) { - playerStore.play(music: music, in: musicList) - } onAddToMusicbill: { - musicForMusicbillSelection = music - } onSaveOffline: { - playerStore.saveOffline(music) - } - } - } - } - } - .refreshable { - await playerStore.loadArtist(id: artist.id, force: true) - } - } - - private func availableRoles(for detail: ArtistDetail) -> [ArtistMusicRole] { - ArtistMusicRole.allCases.filter { role in - !musicList(for: role, in: detail).isEmpty - } - } - - private func musicList(for role: ArtistMusicRole, in detail: ArtistDetail) -> [Music] { - switch role { - case .performer: - return detail.performerMusicList - case .lyricist: - return detail.lyricistMusicList - case .composer: - return detail.composerMusicList - } - } - - private func uniqueMusicList(in detail: ArtistDetail) -> [Music] { - var seenIDs = Set() - return (detail.performerMusicList + detail.lyricistMusicList + detail.composerMusicList) - .filter { music in - seenIDs.insert(music.id).inserted - } - } -} - -private struct PublicMusicbillSearchRow: View { - let musicbill: PublicMusicbillSearchItem - let onOpen: () -> Void - - var body: some View { - Button(action: onOpen) { - HStack(spacing: 12) { - ArtworkView( - urlString: musicbill.cover, - systemImage: "music.note.list", - size: 50 - ) - - VStack(alignment: .leading, spacing: 4) { - Text(musicbill.name) - .font(.headline) - .lineLimit(1) - Text(musicbill.user.nickname) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - - VStack(alignment: .trailing, spacing: 2) { - Text("\(musicbill.musicCount)") - .font(.headline.monospacedDigit()) - Text("Songs") - .font(.caption) - .foregroundStyle(.secondary) - } - - Image(systemName: "chevron.right") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.tertiary) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } -} - -private struct PublicMusicbillDetailView: View { - let musicbill: PublicMusicbillSearchItem - @ObservedObject var playerStore: PlayerStore - - @Environment(\.dismiss) private var dismiss - @State private var musicForMusicbillSelection: Music? - @State private var userForDetail: UserDetailTarget? - - private var detail: PublicMusicbillDetail? { - playerStore.publicMusicbillDetails[musicbill.id] - } - - var body: some View { - NavigationStack { - Group { - if let detail { - detailContent(detail) - } else if playerStore.loadingPublicMusicbillIDs.contains(musicbill.id) { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ContentUnavailableView( - "Musicbill Not Loaded", - systemImage: "music.note.list", - description: Text("Pull to refresh or try again.") - ) - } - } - .navigationTitle(detail?.name ?? musicbill.name) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - dismiss() - } - } - - if let detail { - ToolbarItem(placement: .primaryAction) { - Button { - Task { - await playerStore.setPublicMusicbillCollected( - id: detail.id, - collected: !detail.collected - ) - } - } label: { - Image(systemName: detail.collected ? "star.fill" : "star") - } - .disabled(playerStore.collectingPublicMusicbillIDs.contains(detail.id)) - .help(detail.collected ? "Uncollect Musicbill" : "Collect Musicbill") - } - } - } - } - .sheet(item: $musicForMusicbillSelection) { music in - AddToMusicbillSheet( - music: music, - playerStore: playerStore - ) - } - .sheet(item: $userForDetail) { target in - UserProfileView( - userID: target.id, - playerStore: playerStore - ) - } - .task(id: musicbill.id) { - await playerStore.loadPublicMusicbill(id: musicbill.id) - } - } - - private func detailContent(_ detail: PublicMusicbillDetail) -> some View { - List { - Section { - HStack(spacing: 14) { - ArtworkView( - urlString: detail.cover.isEmpty ? musicbill.cover : detail.cover, - systemImage: "music.note.list", - size: 72 - ) - - VStack(alignment: .leading, spacing: 5) { - Text(detail.name) - .font(.headline) - Button { - userForDetail = UserDetailTarget(id: detail.user.id) - } label: { - Label(detail.user.nickname, systemImage: "person.crop.circle") - .lineLimit(1) - } - .buttonStyle(.plain) - .font(.subheadline) - .foregroundStyle(.secondary) - HStack(spacing: 10) { - Text("\(detail.musicList.count) songs") - Label(detail.collected ? "Collected" : "Public", systemImage: detail.collected ? "star.fill" : "globe") - } - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) - } - - Section("Songs") { - if detail.musicList.isEmpty { - ContentUnavailableView( - "No Songs", - systemImage: "music.note", - description: Text("This public musicbill does not contain songs.") - ) - } else { - ForEach(detail.musicList) { music in - MusicRow( - music: music, - isCurrent: playerStore.audioPlayer.currentMusic?.id == music.id, - isPlaying: playerStore.audioPlayer.currentMusic?.id == music.id && playerStore.audioPlayer.isPlaying - ) { - playerStore.play(music: music, in: detail.musicList) - } onAddToMusicbill: { - musicForMusicbillSelection = music - } onSaveOffline: { - playerStore.saveOffline(music) - } - } - } - } - } - .refreshable { - await playerStore.loadPublicMusicbill(id: detail.id, force: true) - } - } -} - -private struct MusicbillSharedUsersView: View { - @ObservedObject var playerStore: PlayerStore - let musicbillID: MusicbillDetail.ID - - @Environment(\.dismiss) private var dismiss - @State private var isShowingInviteSheet = false - @State private var userForRemoval: MusicbillUser? - @State private var userForDetail: UserDetailTarget? - @State private var isConfirmingLeave = false - - private var detail: MusicbillDetail? { - playerStore.musicbillDetails[musicbillID] - } - - private var isUpdating: Bool { - playerStore.updatingSharedUserMusicbillIDs.contains(musicbillID) - } - - var body: some View { - NavigationStack { - content - .navigationTitle("Shared Users") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - dismiss() - } - .disabled(isUpdating) - } - - ToolbarItem(placement: .primaryAction) { - Button { - isShowingInviteSheet = true - } label: { - Image(systemName: "person.badge.plus") - } - .disabled(detail == nil || isUpdating) - .help("Invite User") - } - } - } - .sheet(isPresented: $isShowingInviteSheet) { - InviteSharedUserSheet( - playerStore: playerStore, - musicbillID: musicbillID - ) - } - .sheet(item: $userForDetail) { target in - UserProfileView( - userID: target.id, - playerStore: playerStore - ) - } - .confirmationDialog( - "Remove Shared User?", - isPresented: Binding( - get: { userForRemoval != nil }, - set: { isPresented in - if !isPresented { - userForRemoval = nil - } - } - ), - titleVisibility: .visible - ) { - if let userForRemoval { - Button("Remove", role: .destructive) { - Task { - let didRemove = await playerStore.removeSharedUser( - userID: userForRemoval.id, - from: musicbillID - ) - if didRemove { - self.userForRemoval = nil - } - } - } - } - Button("Cancel", role: .cancel) { - userForRemoval = nil - } - } message: { - if let userForRemoval { - Text("Remove \(userForRemoval.nickname) from this shared musicbill.") - } - } - .confirmationDialog( - "Leave Shared Musicbill?", - isPresented: $isConfirmingLeave, - titleVisibility: .visible - ) { - Button("Leave", role: .destructive) { - Task { - let didLeave = await playerStore.leaveSharedMusicbill(id: musicbillID) - if didLeave { - dismiss() - } - } - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This musicbill will be removed from your sidebar.") - } - .task(id: musicbillID) { - await playerStore.loadMusicbill(id: musicbillID) - } - } - - @ViewBuilder - private var content: some View { - if let detail { - userList(detail) - } else if playerStore.loadingMusicbillIDs.contains(musicbillID) { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ContentUnavailableView( - "Shared Users Not Loaded", - systemImage: "person.2", - description: Text("Pull to refresh or try again.") - ) - } - } - - private func userList(_ detail: MusicbillDetail) -> some View { - let isOwner = playerStore.isMusicbillOwner(detail) - - return List { - Section("Owner") { - MusicbillSharedUserRow( - user: detail.owner, - role: .owner, - canRemove: false, - isUpdating: isUpdating - ) { - userForDetail = UserDetailTarget(id: detail.owner.id) - } onRemove: {} - } - - Section("Shared Users") { - if detail.sharedUserList.isEmpty { - ContentUnavailableView( - "No Shared Users", - systemImage: "person.2.slash", - description: Text("Invite a user to share this musicbill.") - ) - } else { - ForEach(detail.sharedUserList) { sharedUser in - MusicbillSharedUserRow( - user: sharedUser, - role: sharedUser.accepted == false ? .pending : .accepted, - canRemove: isOwner, - isUpdating: isUpdating - ) { - userForDetail = UserDetailTarget(id: sharedUser.id) - } onRemove: { - userForRemoval = sharedUser - } - } - } - } - - if !isOwner { - Section { - Button(role: .destructive) { - isConfirmingLeave = true - } label: { - Label("Leave Shared Musicbill", systemImage: "rectangle.portrait.and.arrow.right") - } - .disabled(isUpdating) - } - } - } - .refreshable { - await playerStore.loadMusicbill(id: detail.id, force: true) - } - .overlay { - if isUpdating { - ProgressView() - .padding(16) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - } - } -} - -private enum MusicbillSharedUserRole: Equatable { - case owner - case accepted - case pending - - var title: String { - switch self { - case .owner: - return "Owner" - case .accepted: - return "Accepted" - case .pending: - return "Pending" - } - } - - var systemImage: String { - switch self { - case .owner: - return "crown" - case .accepted: - return "checkmark.circle" - case .pending: - return "clock" - } - } -} - -private struct MusicbillSharedUserRow: View { - let user: MusicbillUser - let role: MusicbillSharedUserRole - let canRemove: Bool - let isUpdating: Bool - let onOpen: () -> Void - let onRemove: () -> Void - - var body: some View { - HStack(spacing: 12) { - Button(action: onOpen) { - HStack(spacing: 12) { - ArtworkView( - urlString: user.avatar, - systemImage: "person.crop.square", - size: 44 - ) - - VStack(alignment: .leading, spacing: 4) { - Text(user.nickname) - .lineLimit(1) - - Label(role.title, systemImage: role.systemImage) - .font(.footnote) - .foregroundStyle(role == .pending ? Color.orange : Color.secondary) - .lineLimit(1) - } - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - Spacer() - - if canRemove { - Button(role: .destructive, action: onRemove) { - Image(systemName: "trash") - } - .disabled(isUpdating) - .help("Remove User") - } - } - .padding(.vertical, 4) - } -} - -private struct InviteSharedUserSheet: View { - @ObservedObject var playerStore: PlayerStore - let musicbillID: MusicbillDetail.ID - - @Environment(\.dismiss) private var dismiss - @State private var username = "" - - private var trimmedUsername: String { - username.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private var isSaving: Bool { - playerStore.updatingSharedUserMusicbillIDs.contains(musicbillID) - } - - private var canSubmit: Bool { - !trimmedUsername.isEmpty && - trimmedUsername.count <= 16 && - !isSaving - } - - var body: some View { - NavigationStack { - Form { - Section { - TextField("Username", text: $username) - #if os(iOS) - .textInputAutocapitalization(.never) - #endif - .autocorrectionDisabled() - .onChange(of: username) { _, value in - username = String(value.trimmingCharacters(in: .whitespacesAndNewlines).prefix(16)) - } - .onSubmit { - guard canSubmit else { return } - Task { - await submit() - } - } - - LabeledContent("Characters", value: "\(trimmedUsername.count)/16") - .font(.footnote) - .foregroundStyle(trimmedUsername.count > 16 ? Color.red : Color.secondary) - } footer: { - Text("The user will receive a shared musicbill invitation.") - } - } - .navigationTitle("Invite User") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - .disabled(isSaving) - } - - ToolbarItem(placement: .confirmationAction) { - Button { - Task { - await submit() - } - } label: { - if isSaving { - ProgressView() - } else { - Text("Invite") - } - } - .disabled(!canSubmit) - } - } - } - } - - private func submit() async { - let didInvite = await playerStore.inviteSharedUser( - username: trimmedUsername, - to: musicbillID - ) - if didInvite { - dismiss() - } - } -} - -private struct MusicbillDetailView: View { - @ObservedObject var playerStore: PlayerStore - let musicbillID: MusicbillDetail.ID - @State private var isShowingRenameSheet = false - @State private var isShowingSharedUsers = false - @State private var isConfirmingPublish = false - @State private var captchaAction: MusicbillCaptchaAction? - @State private var musicForMusicbillSelection: Music? - - private var detail: MusicbillDetail? { - playerStore.musicbillDetails[musicbillID] - } - - var body: some View { - Group { - if let detail { - musicList(detail) - } else if playerStore.loadingMusicbillIDs.contains(musicbillID) { - ProgressView() - } else { - ContentUnavailableView( - "Musicbill Not Loaded", - systemImage: "music.note.list", - description: Text("Pull to refresh or choose another musicbill.") - ) - } - } - .navigationTitle(detail?.name ?? playerStore.summary(for: musicbillID)?.name ?? "Musicbill") - .toolbar { - if let detail { - ToolbarItem(placement: .primaryAction) { - musicbillMenu(detail) - } - } - } - .sheet(isPresented: $isShowingRenameSheet) { - MusicbillNameSheet( - title: "Rename Musicbill", - initialName: detail?.name ?? playerStore.summary(for: musicbillID)?.name ?? "", - submitTitle: "Save", - isSaving: playerStore.isSavingMusicbill - ) { name in - await playerStore.renameMusicbill(id: musicbillID, name: name) - } - } - .sheet(isPresented: $isShowingSharedUsers) { - MusicbillSharedUsersView( - playerStore: playerStore, - musicbillID: musicbillID - ) - } - .sheet(item: $captchaAction) { action in - MusicbillCaptchaActionSheet( - action: action, - playerStore: playerStore - ) - } - .sheet(item: $musicForMusicbillSelection) { music in - AddToMusicbillSheet( - music: music, - playerStore: playerStore - ) - } - .confirmationDialog( - "Make this musicbill public?", - isPresented: $isConfirmingPublish, - titleVisibility: .visible - ) { - Button("Make Public") { - Task { - await playerStore.publishMusicbill(id: musicbillID) - } - } - Button("Cancel", role: .cancel) {} - } message: { - Text("Anyone can view a public musicbill.") - } - .task(id: musicbillID) { - await playerStore.loadMusicbill(id: musicbillID) - } - .refreshable { - await playerStore.loadMusicbill(id: musicbillID, force: true) - } - } - - private func musicbillMenu(_ detail: MusicbillDetail) -> some View { - Menu { - Button { - isShowingRenameSheet = true - } label: { - Label("Rename", systemImage: "pencil") - } - - Button { - isShowingSharedUsers = true - } label: { - Label("Shared Users", systemImage: "person.2") - } - - if !detail.isPublic { - Button { - isConfirmingPublish = true - } label: { - Label("Make Public", systemImage: "globe") - } - } else { - Button(role: .destructive) { - captchaAction = .unpublish(id: detail.id, name: detail.name) - } label: { - Label("Make Private", systemImage: "lock") - } - } - - if playerStore.canDeleteMusicbill(detail) { - Divider() - - Button(role: .destructive) { - captchaAction = .delete(id: detail.id, name: detail.name) - } label: { - Label("Delete", systemImage: "trash") - } - } - } label: { - Image(systemName: "ellipsis.circle") - } - .help("Musicbill Actions") - } - - private func musicList(_ detail: MusicbillDetail) -> some View { - List { - Section { - HStack(spacing: 14) { - ArtworkView( - urlString: detail.cover, - systemImage: "music.note.list", - size: 72 - ) - - VStack(alignment: .leading, spacing: 5) { - Text(detail.name) - .font(.headline) - Text(detail.owner.nickname) - .foregroundStyle(.secondary) - Text("\(detail.musicList.count) songs") - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) - } - - Section("Songs") { - if detail.musicList.isEmpty { - ContentUnavailableView( - "No Songs", - systemImage: "music.note", - description: Text("This musicbill does not contain songs yet.") - ) - } else { - ForEach(detail.musicList) { music in - MusicRow( - music: music, - isCurrent: playerStore.audioPlayer.currentMusic?.id == music.id, - isPlaying: playerStore.audioPlayer.currentMusic?.id == music.id && playerStore.audioPlayer.isPlaying - ) { - playerStore.play(music: music, in: detail) - } onAddToMusicbill: { - musicForMusicbillSelection = music - } onRemoveFromMusicbill: { - Task { - await playerStore.removeMusic(music, from: detail.id) - } - } onSaveOffline: { - playerStore.saveOffline(music) - } - } - } - } - } - } -} - -private struct MusicRow: View { - let music: Music - let isCurrent: Bool - let isPlaying: Bool - let lyricSnippet: [LyricSearchSnippetLine] - let lyricKeyword: String - let onPlay: () -> Void - let onAddToMusicbill: () -> Void - var onRemoveFromMusicbill: (() -> Void)? = nil - var onSaveOffline: (() -> Void)? = nil - - init( - music: Music, - isCurrent: Bool, - isPlaying: Bool, - lyricSnippet: [LyricSearchSnippetLine] = [], - lyricKeyword: String = "", - onPlay: @escaping () -> Void, - onAddToMusicbill: @escaping () -> Void, - onRemoveFromMusicbill: (() -> Void)? = nil, - onSaveOffline: (() -> Void)? = nil - ) { - self.music = music - self.isCurrent = isCurrent - self.isPlaying = isPlaying - self.lyricSnippet = lyricSnippet - self.lyricKeyword = lyricKeyword - self.onPlay = onPlay - self.onAddToMusicbill = onAddToMusicbill - self.onRemoveFromMusicbill = onRemoveFromMusicbill - self.onSaveOffline = onSaveOffline - } - - var body: some View { - Button(action: onPlay) { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 12) { - ArtworkView( - urlString: music.coverThumbnail?.isEmpty == false ? music.coverThumbnail : music.cover, - systemImage: "music.note", - size: 44 - ) - - VStack(alignment: .leading, spacing: 3) { - Text(music.name) - .lineLimit(1) - Text(music.performerLine) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - - if isCurrent { - Image(systemName: isPlaying ? "speaker.wave.2.fill" : "pause.circle") - .foregroundStyle(.tint) - } - } - - if !lyricSnippet.isEmpty { - lyricSnippetView - } - } - } - .buttonStyle(.plain) - .contextMenu { - Button(action: onAddToMusicbill) { - Label("Add to Musicbill", systemImage: "text.badge.plus") - } - - if let onSaveOffline { - Button(action: onSaveOffline) { - Label("Save for Offline", systemImage: "arrow.down.circle") - } - } - - if let onRemoveFromMusicbill { - Button(role: .destructive, action: onRemoveFromMusicbill) { - Label("Remove from This Musicbill", systemImage: "minus.circle") - } - } - } - .swipeActions(edge: .leading, allowsFullSwipe: false) { - Button(action: onAddToMusicbill) { - Label("Add", systemImage: "text.badge.plus") - } - .tint(.accentColor) - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if let onRemoveFromMusicbill { - Button(role: .destructive, action: onRemoveFromMusicbill) { - Label("Remove", systemImage: "minus.circle") - } - } - } - } - - private var lyricSnippetView: some View { - VStack(alignment: .leading, spacing: 4) { - ForEach(lyricSnippet) { line in - Text(line.text) - .font(line.isMatch ? .callout.weight(.semibold) : .footnote) - .foregroundStyle(line.isMatch ? Color.accentColor : Color.secondary) - .lineLimit(2) - } - } - .padding(.leading, 56) - .accessibilityLabel("Matched lyrics") - } -} - -private enum NowPlayingLyricState: Equatable { - case idle - case loading - case instrumental - case empty - case loaded([LyricItem]) - case failed(String) -} - -private struct NowPlayingDetailView: View { - @ObservedObject var playerStore: PlayerStore - @ObservedObject var audioPlayer: AudioPlayerController - @Environment(\.dismiss) private var dismiss - @State private var lyricState: NowPlayingLyricState = .idle - @State private var musicForMusicbillSelection: Music? - @State private var artistForDetail: ArtistSearchItem? - @State private var publicMusicbillForDetail: PublicMusicbillSearchItem? - @State private var userForDetail: UserDetailTarget? - - var body: some View { - NavigationStack { - Group { - if let music = audioPlayer.currentMusic { - ScrollView { - VStack(spacing: 24) { - ArtworkView( - urlString: music.cover.isEmpty ? music.coverThumbnail : music.cover, - systemImage: "music.note", - size: 260 - ) - .shadow(radius: 12, y: 6) - - titleBlock(for: music) - progressBlock - detailTransportControls - metadataBlock(for: music) - lyricsBlock(for: music) - } - .frame(maxWidth: 520) - .frame(maxWidth: .infinity) - .padding() - } - } else { - ContentUnavailableView( - "No Music Playing", - systemImage: "music.note", - description: Text("Play a song before opening Now Playing.") - ) - } - } - .navigationTitle("Now Playing") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - dismiss() - } - } - - ToolbarItem(placement: .primaryAction) { - if let music = audioPlayer.currentMusic { - Button { - musicForMusicbillSelection = music - } label: { - Image(systemName: "text.badge.plus") - } - .help("Add to Musicbill") - } - } - } - } - .sheet(item: $musicForMusicbillSelection) { music in - AddToMusicbillSheet( - music: music, - playerStore: playerStore - ) - } - .sheet(item: $artistForDetail) { artist in - ArtistDetailView( - artist: artist, - playerStore: playerStore - ) - } - .sheet(item: $publicMusicbillForDetail) { musicbill in - PublicMusicbillDetailView( - musicbill: musicbill, - playerStore: playerStore - ) - } - .sheet(item: $userForDetail) { target in - UserProfileView( - userID: target.id, - playerStore: playerStore - ) - } - .task(id: audioPlayer.currentMusic?.id) { - guard let music = audioPlayer.currentMusic else { - lyricState = .idle - return - } - await loadLyrics(for: music) - } - .task(id: audioPlayer.currentMusic?.id) { - guard let musicID = audioPlayer.currentMusic?.id else { - return - } - await playerStore.loadMusicDetail(id: musicID) - } - } - - private func titleBlock(for music: Music) -> some View { - VStack(spacing: 6) { - Text(music.name) - .font(.title2.bold()) - .multilineTextAlignment(.center) - - if let alias = music.aliases.first { - Text(alias) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - - Text(music.performerLine) - .font(.headline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - } - - @ViewBuilder - private var progressBlock: some View { - if audioPlayer.duration > 0 { - VStack(spacing: 8) { - Slider( - value: Binding( - get: { - min(audioPlayer.currentTime, audioPlayer.duration) - }, - set: { value in - audioPlayer.currentTime = value - } - ), - in: 0...max(audioPlayer.duration, 1), - onEditingChanged: { isEditing in - if !isEditing { - audioPlayer.seek(to: audioPlayer.currentTime) - } - } - ) - - HStack { - Text(formatPlaybackTime(audioPlayer.currentTime)) - Spacer() - Text(formatPlaybackTime(audioPlayer.duration)) - } - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - } - } - } - - private var detailTransportControls: some View { - HStack(spacing: 24) { - Button { - audioPlayer.previous() - } label: { - Image(systemName: "backward.fill") - } - .help("Previous") - - Button { - audioPlayer.togglePlayback() - } label: { - Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") - .frame(width: 28, height: 28) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .help(audioPlayer.isPlaying ? "Pause" : "Play") - - Button { - audioPlayer.next() - } label: { - Image(systemName: "forward.fill") - } - .help("Next") - } - .font(.title3) - } - - private func metadataBlock(for music: Music) -> some View { - let detail = playerStore.musicDetails[music.id] - let isLoadingDetail = playerStore.loadingMusicDetailIDs.contains(music.id) - - return VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Details") - .font(.headline) - - Spacer() - - if isLoadingDetail { - ProgressView() - .controlSize(.small) - } - } - - VStack(spacing: 10) { - detailRow("Type", music.type == 2 ? "Instrumental" : "Song") - - if let detail { - if let year = detail.year { - detailRow("Year", String(year)) - } - - if detail.createTimestamp > 0 { - detailRow( - "Added", - formatCicadaTimestamp( - detail.createTimestamp, - date: .abbreviated, - time: .omitted - ) - ) - } - - if let assetDurationMs = detail.assetDurationMs { - detailRow("Duration", formatPlaybackTime(Double(assetDurationMs) / 1000)) - } - - if let assetCodec = detail.assetCodec, !assetCodec.isEmpty { - detailRow("Codec", assetCodec.uppercased()) - } - - if let assetBitRate = detail.assetBitRate { - detailRow("Bit Rate", formatAssetBitRate(assetBitRate)) - } - - detailRow("Heat", String(detail.heat)) - detailRow("Musicbills", String(detail.musicbillCount)) - } else { - detailRow("Performers", artistLine(music.performers)) - - if !music.lyricists.isEmpty { - detailRow("Lyricists", artistLine(music.lyricists)) - } - - if !music.composers.isEmpty { - detailRow("Composers", artistLine(music.composers)) - } - } - } - - if let detail { - artistSection("Performers", artists: detail.performers) - artistSection("Lyricists", artists: detail.lyricists) - artistSection("Composers", artists: detail.composers) - relatedPublicMusicbillSection(detail.relatedPublicMusicbillList) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - @ViewBuilder - private func artistSection(_ title: String, artists: [ArtistSearchItem]) -> some View { - if !artists.isEmpty { - VStack(alignment: .leading, spacing: 10) { - Text(title) - .font(.headline) - - VStack(spacing: 0) { - ForEach(Array(artists.enumerated()), id: \.element.id) { index, artist in - Button { - artistForDetail = artist - } label: { - MusicDetailArtistRow(artist: artist) - } - .buttonStyle(.plain) - - if index < artists.count - 1 { - Divider() - .padding(.leading, 52) - } - } - } - } - } - } - - @ViewBuilder - private func relatedPublicMusicbillSection(_ musicbills: [PublicMusicbillSearchItem]) -> some View { - if !musicbills.isEmpty { - let visibleMusicbills = Array(musicbills.prefix(5)) - - VStack(alignment: .leading, spacing: 10) { - Text("Related Musicbills") - .font(.headline) - - VStack(spacing: 0) { - ForEach(Array(visibleMusicbills.enumerated()), id: \.element.id) { index, musicbill in - RelatedPublicMusicbillRow( - musicbill: musicbill, - onOpen: { - publicMusicbillForDetail = musicbill - }, - onOpenUser: { - userForDetail = UserDetailTarget(id: musicbill.user.id) - } - ) - - if index < visibleMusicbills.count - 1 { - Divider() - .padding(.leading, 52) - } - } - } - } - } - } - - private func formatAssetBitRate(_ bitRate: Int) -> String { - if bitRate >= 1000 { - return "\(bitRate / 1000) kbps" - } - return "\(bitRate) bps" - } - - private func detailRow(_ title: String, _ value: String) -> some View { - LabeledContent { - Text(value) - .multilineTextAlignment(.trailing) - } label: { - Text(title) - } - .font(.subheadline) - } - - private func lyricsBlock(for music: Music) -> some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("Lyrics") - .font(.headline) - - Spacer() - - if case .failed = lyricState { - Button { - Task { - await loadLyrics(for: music) - } - } label: { - Label("Retry", systemImage: "arrow.clockwise") - } - .buttonStyle(.borderless) - } - } - - lyricContent - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - @ViewBuilder - private var lyricContent: some View { - switch lyricState { - case .idle, .loading: - HStack(spacing: 10) { - ProgressView() - Text("Loading Lyrics") - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, minHeight: 120) - - case .instrumental: - Label("Instrumental tracks do not have lyrics.", systemImage: "music.note") - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, minHeight: 120) - - case .empty: - Label("No lyrics", systemImage: "text.quote") - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, minHeight: 120) - - case .loaded(let lyrics): - let lines = parseLyricItems(lyrics) - if lines.isEmpty { - Label("No lyrics", systemImage: "text.quote") - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, minHeight: 120) - } else { - LyricLinesView( - lines: lines, - currentTime: audioPlayer.currentTime - ) - } - - case .failed(let message): - Label(message, systemImage: "exclamationmark.triangle") - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, minHeight: 120) - } - } - - private func loadLyrics(for music: Music) async { - lyricState = .loading - let result = await playerStore.loadLyrics(for: music) - guard !Task.isCancelled, audioPlayer.currentMusic?.id == music.id else { - return - } - - switch result { - case .instrumental: - lyricState = .instrumental - case .empty: - lyricState = .empty - case .loaded(let lyrics): - lyricState = .loaded(lyrics) - case .failed(let message): - lyricState = .failed(message) - } - } - - private func artistLine(_ artists: [ArtistSummary]) -> String { - artists.map(\.name).joined(separator: ", ") - } -} - -private struct MusicDetailArtistRow: View { - let artist: ArtistSearchItem - - var body: some View { - HStack(spacing: 12) { - ArtworkView( - urlString: artist.avatar, - systemImage: "person.crop.square", - size: 40 - ) - - VStack(alignment: .leading, spacing: 3) { - Text(artist.name) - .font(.subheadline.weight(.semibold)) - .lineLimit(1) - - if !artist.aliases.isEmpty { - Text(artist.aliases.joined(separator: " / ")) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.tertiary) - } - .padding(.vertical, 8) - .contentShape(Rectangle()) - } -} - -private struct RelatedPublicMusicbillRow: View { - let musicbill: PublicMusicbillSearchItem - let onOpen: () -> Void - let onOpenUser: () -> Void - - var body: some View { - HStack(spacing: 8) { - Button(action: onOpen) { - HStack(spacing: 12) { - ArtworkView( - urlString: musicbill.cover, - systemImage: "music.note.list", - size: 40 - ) - - VStack(alignment: .leading, spacing: 3) { - Text(musicbill.name) - .font(.subheadline.weight(.semibold)) - .lineLimit(1) - Text(musicbill.user.nickname) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - - VStack(alignment: .trailing, spacing: 2) { - Text("\(musicbill.musicCount)") - .font(.subheadline.monospacedDigit().weight(.semibold)) - Text("Songs") - .font(.caption) - .foregroundStyle(.secondary) - } - - Image(systemName: "chevron.right") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.tertiary) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - Button(action: onOpenUser) { - Image(systemName: "person.crop.circle") - .font(.body) - .frame(width: 28, height: 28) - } - .buttonStyle(.borderless) - .help("Open Owner") - } - .padding(.vertical, 8) - } -} - -private struct QueueView: View { - @ObservedObject var audioPlayer: AudioPlayerController - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - Group { - if audioPlayer.queue.isEmpty { - ContentUnavailableView( - "Queue Empty", - systemImage: "list.bullet", - description: Text("Play a song to start a queue.") - ) - } else { - List { - Section("\(audioPlayer.queue.count) Songs") { - ForEach(Array(audioPlayer.queue.enumerated()), id: \.offset) { index, music in - Button { - audioPlayer.playQueueItem(at: index) - } label: { - QueueRow( - index: index, - music: music, - isCurrent: index == audioPlayer.currentQueueIndex, - isPlaying: index == audioPlayer.currentQueueIndex && audioPlayer.isPlaying - ) - } - .buttonStyle(.plain) - } - } - } - } - } - .navigationTitle("Queue") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - dismiss() - } - } - } - } - } -} - -private struct QueueRow: View { - let index: Int - let music: Music - let isCurrent: Bool - let isPlaying: Bool - - var body: some View { - HStack(spacing: 12) { - Text("\(index + 1)") - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - .frame(width: 28, alignment: .trailing) - - ArtworkView( - urlString: music.coverThumbnail?.isEmpty == false ? music.coverThumbnail : music.cover, - systemImage: "music.note", - size: 44 - ) - - VStack(alignment: .leading, spacing: 3) { - Text(music.name) - .lineLimit(1) - Text(music.performerLine) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - - if isCurrent { - Image(systemName: isPlaying ? "speaker.wave.2.fill" : "pause.circle") - .foregroundStyle(.tint) - } - } - .contentShape(Rectangle()) - } -} - -private struct LyricTimelineLine: Identifiable, Equatable { - let id: String - let time: Double? - let texts: [String] -} - -private struct LyricLinesView: View { - let lines: [LyricTimelineLine] - let currentTime: Double - - private var activeLineID: String? { - lines - .filter { line in - guard let time = line.time else { return false } - return time <= currentTime + 0.25 - } - .last? - .id - } - - var body: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 14) { - ForEach(lines) { line in - lyricLine(line) - .id(line.id) - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, 18) - } - .frame(height: 320) - .onAppear { - scrollToActiveLine(with: proxy) - } - .onChange(of: activeLineID) { _, _ in - scrollToActiveLine(with: proxy) - } - } - } - - private func lyricLine(_ line: LyricTimelineLine) -> some View { - let isActive = line.id == activeLineID - - return VStack(spacing: 4) { - ForEach(Array(line.texts.enumerated()), id: \.offset) { _, text in - Text(text) - .font(isActive ? .headline : .body) - .fontWeight(isActive ? .semibold : .regular) - .foregroundStyle(isActive ? Color.accentColor : Color.primary) - .multilineTextAlignment(.center) - .lineLimit(nil) - } - } - .frame(maxWidth: .infinity) - .padding(.horizontal, 8) - .animation(.easeInOut(duration: 0.2), value: isActive) - } - - private func scrollToActiveLine(with proxy: ScrollViewProxy) { - guard let activeLineID else { return } - withAnimation(.easeInOut(duration: 0.25)) { - proxy.scrollTo(activeLineID, anchor: .center) - } - } -} - -private struct MiniPlayerView: View { - @ObservedObject var audioPlayer: AudioPlayerController - let onDetails: () -> Void - let onQueue: () -> Void - - var body: some View { - if let music = audioPlayer.currentMusic { - VStack(spacing: 8) { - Divider() - ViewThatFits(in: .horizontal) { - regularControls(for: music) - compactControls(for: music) - } - - if audioPlayer.duration > 0 { - HStack(spacing: 10) { - Text(formatPlaybackTime(audioPlayer.currentTime)) - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - - Slider( - value: Binding( - get: { - min(audioPlayer.currentTime, audioPlayer.duration) - }, - set: { value in - audioPlayer.currentTime = value - } - ), - in: 0...max(audioPlayer.duration, 1), - onEditingChanged: { isEditing in - if !isEditing { - audioPlayer.seek(to: audioPlayer.currentTime) - } - } - ) - - Text(formatPlaybackTime(audioPlayer.duration)) - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - } - } - } - .padding(.horizontal) - .padding(.top, 8) - .padding(.bottom, 6) - .background(.bar) - } - } - - private func regularControls(for music: Music) -> some View { - HStack(spacing: 12) { - nowPlayingButton(for: music, artworkSize: 44) - - Spacer() - - transportControls - queueButton - } - } - - private func compactControls(for music: Music) -> some View { - VStack(spacing: 8) { - HStack(spacing: 12) { - nowPlayingButton(for: music, artworkSize: 40) - - Spacer() - - queueButton - } - - transportControls - } - } - - private func nowPlayingButton(for music: Music, artworkSize: CGFloat) -> some View { - Button(action: onDetails) { - nowPlayingSummary(for: music, artworkSize: artworkSize) - } - .buttonStyle(.plain) - .contentShape(Rectangle()) - .help("Now Playing") - } - - private func nowPlayingSummary(for music: Music, artworkSize: CGFloat) -> some View { - HStack(spacing: 12) { - ArtworkView( - urlString: music.coverThumbnail?.isEmpty == false ? music.coverThumbnail : music.cover, - systemImage: "music.note", - size: artworkSize - ) - - VStack(alignment: .leading, spacing: 2) { - Text(music.name) - .font(.headline) - .lineLimit(1) - Text(music.performerLine) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - .frame(minWidth: 0, alignment: .leading) - } - } - - private var transportControls: some View { - HStack(spacing: 12) { - Button { - audioPlayer.previous() - } label: { - Image(systemName: "backward.fill") - } - .help("Previous") - - Button { - audioPlayer.togglePlayback() - } label: { - Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") - } - .buttonStyle(.borderedProminent) - .help(audioPlayer.isPlaying ? "Pause" : "Play") - - Button { - audioPlayer.next() - } label: { - Image(systemName: "forward.fill") - } - .help("Next") - } - } - - private var queueButton: some View { - Button(action: onQueue) { - Image(systemName: "list.bullet") - } - .disabled(audioPlayer.queue.isEmpty) - .help("Queue") - } - -} - -private struct ParsedLyricEntry { - let timeMillis: Int? - let text: String - let sourceIndex: Int - let sequence: Int -} - -private func parseLyricItems(_ lyrics: [LyricItem]) -> [LyricTimelineLine] { - let entries = lyrics.enumerated().flatMap { sourceIndex, lyric in - parseLRC(lyric.lrc, sourceIndex: sourceIndex) - } - let timedEntries = entries.filter { $0.timeMillis != nil } - - guard !timedEntries.isEmpty else { - return entries - .map(\.text) - .filter { !$0.isEmpty } - .enumerated() - .map { index, text in - LyricTimelineLine( - id: "static-\(index)", - time: nil, - texts: [text] - ) - } - } - - let groupedEntries = Dictionary(grouping: timedEntries) { entry in - entry.timeMillis ?? 0 - } - - return groupedEntries.keys.sorted().map { timeMillis in - let texts = groupedEntries[timeMillis, default: []] - .sorted { lhs, rhs in - if lhs.sourceIndex != rhs.sourceIndex { - return lhs.sourceIndex < rhs.sourceIndex - } - return lhs.sequence < rhs.sequence - } - .map(\.text) - .filter { !$0.isEmpty } - - return LyricTimelineLine( - id: "timed-\(timeMillis)", - time: Double(timeMillis) / 1000, - texts: texts.isEmpty ? [" "] : texts - ) - } -} - -private func parseLRC(_ lrc: String, sourceIndex: Int) -> [ParsedLyricEntry] { - let pattern = #"\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]"# - guard let regex = try? NSRegularExpression(pattern: pattern) else { - return [] - } - - return lrc - .components(separatedBy: .newlines) - .enumerated() - .flatMap { sequence, rawLine -> [ParsedLyricEntry] in - let trimmedLine = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedLine.isEmpty else { return [] } - - let line = rawLine as NSString - let range = NSRange(location: 0, length: line.length) - let matches = regex.matches(in: rawLine, range: range) - - guard !matches.isEmpty else { - if trimmedLine.hasPrefix("[") && trimmedLine.hasSuffix("]") { - return [] - } - return [ - ParsedLyricEntry( - timeMillis: nil, - text: trimmedLine, - sourceIndex: sourceIndex, - sequence: sequence - ), - ] - } - - let textStart = matches.map { NSMaxRange($0.range) }.max() ?? 0 - let text = line - .substring(from: min(textStart, line.length)) - .trimmingCharacters(in: .whitespacesAndNewlines) - - return matches.compactMap { match in - guard let timeMillis = lyricTimeMillis(from: match, in: line) else { - return nil - } - return ParsedLyricEntry( - timeMillis: timeMillis, - text: text, - sourceIndex: sourceIndex, - sequence: sequence - ) - } - } -} - -private func lyricTimeMillis(from match: NSTextCheckingResult, in line: NSString) -> Int? { - guard match.numberOfRanges >= 3 else { return nil } - guard - let minutes = Int(line.substring(with: match.range(at: 1))), - let seconds = Int(line.substring(with: match.range(at: 2))) - else { - return nil - } - - var milliseconds = 0 - if match.numberOfRanges > 3 { - let fractionRange = match.range(at: 3) - if fractionRange.location != NSNotFound, - let rawMilliseconds = Int(line.substring(with: fractionRange)) { - switch fractionRange.length { - case 1: - milliseconds = rawMilliseconds * 100 - case 2: - milliseconds = rawMilliseconds * 10 - default: - milliseconds = rawMilliseconds - } - } - } - - return ((minutes * 60) + seconds) * 1000 + milliseconds -} - -private func dateFromCicadaTimestamp(_ timestamp: TimeInterval) -> Date { - let seconds = timestamp > 10_000_000_000 ? timestamp / 1000 : timestamp - return Date(timeIntervalSince1970: seconds) -} - -private func formatCicadaTimestamp( - _ timestamp: TimeInterval, - date: Date.FormatStyle.DateStyle, - time: Date.FormatStyle.TimeStyle -) -> String { - dateFromCicadaTimestamp(timestamp).formatted(date: date, time: time) -} - -private func displayDeviceName(_ session: AuthSession) -> String { - let name = session.deviceName.trimmingCharacters(in: .whitespacesAndNewlines) - return name.isEmpty ? "Unknown Device" : name -} - -private func formatPlaybackTime(_ seconds: Double) -> String { - guard seconds.isFinite else { return "0:00" } - let totalSeconds = max(0, Int(seconds.rounded())) - let hours = totalSeconds / 3600 - let minutes = (totalSeconds % 3600) / 60 - let remainingSeconds = totalSeconds % 60 - - if hours > 0 { - return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", remainingSeconds))" - } - return "\(minutes):\(String(format: "%02d", remainingSeconds))" -} - -// MARK: - Exploration - -private struct ExplorationView: View { - @ObservedObject var playerStore: PlayerStore - - @Environment(\.dismiss) private var dismiss - @State private var artistForDetail: ArtistSearchItem? - @State private var publicMusicbillForDetail: PublicMusicbillSearchItem? - - var body: some View { - NavigationStack { - content - .navigationTitle("Explore") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - dismiss() - } - } - - ToolbarItem(placement: .primaryAction) { - Button { - Task { - await playerStore.loadExploration(force: true) - } - } label: { - Image(systemName: "arrow.clockwise") - } - .disabled(playerStore.isLoadingExploration) - .help("Refresh") - } - } - } - .task { - await playerStore.loadExploration() - } - .sheet(item: $artistForDetail) { artist in - ArtistDetailView(artist: artist, playerStore: playerStore) - } - .sheet(item: $publicMusicbillForDetail) { musicbill in - PublicMusicbillDetailView(musicbill: musicbill, playerStore: playerStore) - } - } - - @ViewBuilder - private var content: some View { - if playerStore.isLoadingExploration && playerStore.exploration == nil { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let exploration = playerStore.exploration, !exploration.isEmpty { - List { - musicSection("For You", items: exploration.musicList) - artistSection("Artists", items: exploration.artistList) - musicbillSection("Public Musicbills", items: exploration.publicMusicbillList) - musicSection("Recently Added", items: exploration.recentMusicList) - artistSection("Recent Artists", items: exploration.recentArtistList) - musicbillSection("Recent Musicbills", items: exploration.recentPublicMusicbillList) - } - .overlay { - if playerStore.isLoadingExploration { - ProgressView() - .padding(16) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - } - } else if playerStore.exploration != nil { - ContentUnavailableView( - "Nothing to Explore", - systemImage: "sparkles", - description: Text("Check back later for new picks.") - ) - } else { - ContentUnavailableView( - "Explore", - systemImage: "sparkles", - description: Text("Discover music, artists, and public musicbills.") - ) - } - } - - @ViewBuilder - private func musicSection(_ title: String, items: [ExplorationMusicItem]) -> some View { - if !items.isEmpty { - Section(title) { - ForEach(items) { music in - ExplorationMusicRow( - music: music, - isCurrent: playerStore.audioPlayer.currentMusic?.id == music.id, - isPlaying: playerStore.audioPlayer.currentMusic?.id == music.id && playerStore.audioPlayer.isPlaying - ) { - Task { - await playerStore.playMusic(id: music.id) - } - } - } - } - } - } - - @ViewBuilder - private func artistSection(_ title: String, items: [ExplorationArtistItem]) -> some View { - if !items.isEmpty { - Section(title) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 14) { - ForEach(items) { artist in - ExplorationArtistCard(artist: artist) { - artistForDetail = artist.asSearchItem() - } - } - } - .padding(.vertical, 4) - } - .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) - } - } - } - - @ViewBuilder - private func musicbillSection(_ title: String, items: [ExplorationPublicMusicbillItem]) -> some View { - if !items.isEmpty { - Section(title) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 14) { - ForEach(items) { musicbill in - ExplorationMusicbillCard(musicbill: musicbill) { - publicMusicbillForDetail = musicbill.asSearchItem() - } - } - } - .padding(.vertical, 4) - } - .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) - } - } - } -} - -private struct ExplorationMusicRow: View { - let music: ExplorationMusicItem - let isCurrent: Bool - let isPlaying: Bool - let onPlay: () -> Void - - var body: some View { - Button(action: onPlay) { - HStack(spacing: 12) { - ArtworkView( - urlString: music.coverThumbnail?.isEmpty == false ? music.coverThumbnail : music.cover, - systemImage: "music.note", - size: 44 - ) - - VStack(alignment: .leading, spacing: 3) { - Text(music.name) - .lineLimit(1) - Text(music.performerLine) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - - if isCurrent { - Image(systemName: isPlaying ? "speaker.wave.2.fill" : "pause.circle") - .foregroundStyle(.tint) - } - } - } - .buttonStyle(.plain) - } -} - -private struct ExplorationArtistCard: View { - let artist: ExplorationArtistItem - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - VStack(spacing: 8) { - ArtworkView( - urlString: artist.photos.first?.thumbnail?.isEmpty == false ? artist.photos.first?.thumbnail : artist.avatar, - systemImage: "music.mic", - size: 96 - ) - .clipShape(Circle()) - - Text(artist.name) - .font(.footnote) - .lineLimit(1) - .frame(width: 96) - } - } - .buttonStyle(.plain) - } -} - -private struct ExplorationMusicbillCard: View { - let musicbill: ExplorationPublicMusicbillItem - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - VStack(alignment: .leading, spacing: 8) { - ArtworkView( - urlString: musicbill.cover, - systemImage: "music.note.list", - size: 120 - ) - - Text(musicbill.name) - .font(.footnote) - .lineLimit(1) - Text(musicbill.user.nickname) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - .frame(width: 120, alignment: .leading) - } - .buttonStyle(.plain) - } -} - -// MARK: - Radio - -private struct RadioView: View { - @ObservedObject var playerStore: PlayerStore - @ObservedObject var audioPlayer: AudioPlayerController - - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - content - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .navigationTitle("Radio") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - dismiss() - } - } - - if audioPlayer.isRadioMode { - ToolbarItem(placement: .primaryAction) { - Button(role: .destructive) { - playerStore.stopRadio() - } label: { - Text("Stop") - } - } - } - } - } - } - - @ViewBuilder - private var content: some View { - if audioPlayer.isRadioMode, let music = audioPlayer.currentMusic { - playingView(music: music) - } else if playerStore.isRadioLoading { - ProgressView("Tuning in…") - } else { - startView - } - } - - private var startView: some View { - ContentUnavailableView { - Label("Radio", systemImage: "dot.radiowaves.left.and.right") - } description: { - Text("Play an endless shuffle of random music.") - } actions: { - Button { - Task { - await playerStore.startRadio() - } - } label: { - Text("Start Radio") - .frame(maxWidth: 220) - } - .buttonStyle(.borderedProminent) - } - } - - private func playingView(music: Music) -> some View { - VStack(spacing: 28) { - Spacer() - - ArtworkView( - urlString: music.cover.isEmpty ? music.coverThumbnail : music.cover, - systemImage: "dot.radiowaves.left.and.right", - size: 260 - ) - - VStack(spacing: 6) { - Text(music.name) - .font(.title2.bold()) - .multilineTextAlignment(.center) - .lineLimit(2) - Text(music.performerLine) - .font(.callout) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - HStack(spacing: 48) { - Button { - audioPlayer.togglePlayback() - } label: { - Image(systemName: audioPlayer.isPlaying ? "pause.circle.fill" : "play.circle.fill") - .font(.system(size: 64)) - } - .buttonStyle(.plain) - - Button { - playerStore.skipRadio() - } label: { - Image(systemName: "forward.fill") - .font(.system(size: 32)) - } - .buttonStyle(.plain) - } - - if let upcoming { - Label("Next: \(upcoming.name)", systemImage: "music.note") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - } - } - - private var upcoming: Music? { - let nextIndex = audioPlayer.currentQueueIndex + 1 - guard audioPlayer.queue.indices.contains(nextIndex) else { return nil } - return audioPlayer.queue[nextIndex] + private func authKey(server: ServerRecord, user: ServerUserRecord) -> String { + "\(server.origin)|\(user.id)|\(user.token)" } } diff --git a/apps/apple/Cicada/Features/Settings/SettingsView.swift b/apps/apple/Cicada/Features/Settings/SettingsView.swift index b575ab30d..f11a9393b 100644 --- a/apps/apple/Cicada/Features/Settings/SettingsView.swift +++ b/apps/apple/Cicada/Features/Settings/SettingsView.swift @@ -4,6 +4,8 @@ struct SettingsView: View { @ObservedObject var settings: AppSettingsStore @ObservedObject var playerStore: PlayerStore @ObservedObject var offlineCacheManager: OfflineCacheManager + let showsDoneButton: Bool + let embedsInNavigationStack: Bool @Environment(\.dismiss) private var dismiss @@ -13,71 +15,97 @@ struct SettingsView: View { ByteCountFormatter.string(fromByteCount: offlineCacheManager.totalBytes, countStyle: .file) } + init( + settings: AppSettingsStore, + playerStore: PlayerStore, + offlineCacheManager: OfflineCacheManager, + showsDoneButton: Bool = true, + embedsInNavigationStack: Bool = true + ) { + self.settings = settings + self.playerStore = playerStore + self.offlineCacheManager = offlineCacheManager + self.showsDoneButton = showsDoneButton + self.embedsInNavigationStack = embedsInNavigationStack + } + var body: some View { - NavigationStack { - Form { - Section { - Picker("Playback Quality", selection: $settings.musicPlaybackQuality) { - ForEach(MusicPlaybackQuality.allCases) { quality in - Text(quality.displayName).tag(quality) - } + Group { + if embedsInNavigationStack { + NavigationStack { + pageContent + } + } else { + pageContent + } + } + } + + private var pageContent: some View { + Form { + Section { + Picker("Playback Quality", selection: $settings.musicPlaybackQuality) { + ForEach(MusicPlaybackQuality.allCases) { quality in + Text(quality.displayName).tag(quality) } - } header: { - Text("Playback") - } footer: { - Text(settings.musicPlaybackQuality.detail) } + } header: { + Text("Playback") + } footer: { + Text(settings.musicPlaybackQuality.detail) + } - Section { - Picker("Language", selection: $settings.language) { - ForEach(AppLanguageOption.allCases) { option in - Text(option.displayName).tag(option) - } + Section { + Picker("Language", selection: $settings.language) { + ForEach(AppLanguageOption.allCases) { option in + Text(option.displayName).tag(option) } - } header: { - Text("Language") - } footer: { - Text("Affects the language of server-provided content.") } + } header: { + Text("Language") + } footer: { + Text("Affects the language of server-provided content.") + } - Section { - Toggle("Offline Cache", isOn: $settings.offlineCacheEnabled) + Section { + Toggle("Offline Cache", isOn: $settings.offlineCacheEnabled) - Picker("Cache Limit", selection: $settings.offlineCacheLimit) { - ForEach(OfflineCacheLimit.allCases) { limit in - Text(limit.displayName).tag(limit) - } + Picker("Cache Limit", selection: $settings.offlineCacheLimit) { + ForEach(OfflineCacheLimit.allCases) { limit in + Text(limit.displayName).tag(limit) } + } - NavigationLink { - OfflineCacheView( - playerStore: playerStore, - manager: offlineCacheManager - ) - } label: { - LabeledContent("Manage", value: usageText) - } - } header: { - Text("Offline") - } footer: { - Text("Songs you play past 75% are cached automatically. Oldest songs are removed when the limit is reached.") + NavigationLink { + OfflineCacheView( + playerStore: playerStore, + manager: offlineCacheManager + ) + } label: { + LabeledContent("Manage", value: usageText) } + } header: { + Text("Offline") + } footer: { + Text("Songs you play past 75% are cached automatically. Oldest songs are removed when the limit is reached.") + } - Section("About") { - LabeledContent("Version", value: AppVersion.current) + Section("About") { + LabeledContent("Version", value: AppVersion.current) - if let feedbackURL { - Link(destination: feedbackURL) { - Label("Feedback", systemImage: "exclamationmark.bubble") - } + if let feedbackURL { + Link(destination: feedbackURL) { + Label("Feedback", systemImage: "exclamationmark.bubble") } } } - .navigationTitle("Settings") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { + } + .navigationTitle("Settings") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + if showsDoneButton { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() diff --git a/apps/apple/Cicada/Info.plist b/apps/apple/Cicada/Info.plist index 6e0d826f2..1e3955501 100644 --- a/apps/apple/Cicada/Info.plist +++ b/apps/apple/Cicada/Info.plist @@ -36,6 +36,10 @@ UIApplicationSupportsIndirectInputEvents + UIBackgroundModes + + audio + UILaunchScreen UISupportedInterfaceOrientations diff --git a/apps/apple/Cicada/Models/APIModels.swift b/apps/apple/Cicada/Models/APIModels.swift index 072625799..c797a5d13 100644 --- a/apps/apple/Cicada/Models/APIModels.swift +++ b/apps/apple/Cicada/Models/APIModels.swift @@ -291,6 +291,7 @@ struct Music: Decodable, Hashable, Identifiable { struct MusicbillSummary: Decodable, Hashable, Identifiable { let id: String var cover: String + var coverThumbnail: String? let name: String let isPublic: Bool let createTimestamp: TimeInterval @@ -300,6 +301,7 @@ struct MusicbillSummary: Decodable, Hashable, Identifiable { enum CodingKeys: String, CodingKey { case id case cover + case coverThumbnail case name case isPublic = "public" case createTimestamp @@ -311,6 +313,7 @@ struct MusicbillSummary: Decodable, Hashable, Identifiable { struct MusicbillDetail: Decodable, Hashable, Identifiable { let id: String var cover: String + var coverThumbnail: String? let name: String let isPublic: Bool let createTimestamp: TimeInterval @@ -321,6 +324,7 @@ struct MusicbillDetail: Decodable, Hashable, Identifiable { enum CodingKeys: String, CodingKey { case id case cover + case coverThumbnail case name case isPublic = "public" case createTimestamp @@ -334,6 +338,7 @@ struct PublicMusicbillSearchItem: Decodable, Hashable, Identifiable { let id: String let name: String var cover: String + var coverThumbnail: String? let musicCount: Int let collectionCount: Int var user: MusicbillUser @@ -342,6 +347,7 @@ struct PublicMusicbillSearchItem: Decodable, Hashable, Identifiable { id: String, name: String, cover: String, + coverThumbnail: String?, musicCount: Int, collectionCount: Int, user: MusicbillUser @@ -349,6 +355,7 @@ struct PublicMusicbillSearchItem: Decodable, Hashable, Identifiable { self.id = id self.name = name self.cover = cover + self.coverThumbnail = coverThumbnail self.musicCount = musicCount self.collectionCount = collectionCount self.user = user @@ -358,6 +365,7 @@ struct PublicMusicbillSearchItem: Decodable, Hashable, Identifiable { case id case name case cover + case coverThumbnail case musicCount case collectionCount case user @@ -368,6 +376,7 @@ struct PublicMusicbillSearchItem: Decodable, Hashable, Identifiable { id = try container.decode(String.self, forKey: .id) name = try container.decode(String.self, forKey: .name) cover = try container.decodeIfPresent(String.self, forKey: .cover) ?? "" + coverThumbnail = try container.decodeIfPresent(String.self, forKey: .coverThumbnail) musicCount = try container.decodeIfPresent(Int.self, forKey: .musicCount) ?? 0 collectionCount = try container.decodeIfPresent(Int.self, forKey: .collectionCount) ?? 0 user = try container.decode(MusicbillUser.self, forKey: .user) @@ -383,6 +392,7 @@ struct PublicMusicbillCollectionItem: Decodable, Hashable, Identifiable { let id: String let name: String var cover: String + var coverThumbnail: String? let musicCount: Int var user: MusicbillUser @@ -391,6 +401,7 @@ struct PublicMusicbillCollectionItem: Decodable, Hashable, Identifiable { id: id, name: name, cover: cover, + coverThumbnail: coverThumbnail, musicCount: musicCount, collectionCount: 0, user: user @@ -401,6 +412,7 @@ struct PublicMusicbillCollectionItem: Decodable, Hashable, Identifiable { case id case name case cover + case coverThumbnail case musicCount case user } @@ -410,6 +422,7 @@ struct PublicMusicbillCollectionItem: Decodable, Hashable, Identifiable { id = try container.decode(String.self, forKey: .id) name = try container.decode(String.self, forKey: .name) cover = try container.decodeIfPresent(String.self, forKey: .cover) ?? "" + coverThumbnail = try container.decodeIfPresent(String.self, forKey: .coverThumbnail) musicCount = try container.decodeIfPresent(Int.self, forKey: .musicCount) ?? 0 user = try container.decode(MusicbillUser.self, forKey: .user) } @@ -451,6 +464,7 @@ struct SharedMusicbillInvitation: Decodable, Hashable, Identifiable { struct UserPublicMusicbill: Decodable, Hashable, Identifiable { let id: String var cover: String + var coverThumbnail: String? let name: String let musicCount: Int @@ -459,6 +473,7 @@ struct UserPublicMusicbill: Decodable, Hashable, Identifiable { id: id, name: name, cover: cover, + coverThumbnail: coverThumbnail, musicCount: musicCount, collectionCount: 0, user: user @@ -468,6 +483,7 @@ struct UserPublicMusicbill: Decodable, Hashable, Identifiable { enum CodingKeys: String, CodingKey { case id case cover + case coverThumbnail case name case musicCount } @@ -476,6 +492,7 @@ struct UserPublicMusicbill: Decodable, Hashable, Identifiable { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) cover = try container.decodeIfPresent(String.self, forKey: .cover) ?? "" + coverThumbnail = try container.decodeIfPresent(String.self, forKey: .coverThumbnail) name = try container.decode(String.self, forKey: .name) musicCount = try container.decodeIfPresent(Int.self, forKey: .musicCount) ?? 0 } @@ -549,6 +566,7 @@ struct AuthSession: Decodable, Hashable, Identifiable { struct PublicMusicbillDetail: Decodable, Hashable, Identifiable { let id: String var cover: String + var coverThumbnail: String? let name: String let isPublic: Bool var user: MusicbillUser @@ -558,6 +576,7 @@ struct PublicMusicbillDetail: Decodable, Hashable, Identifiable { enum CodingKeys: String, CodingKey { case id case cover + case coverThumbnail case name case isPublic = "public" case user @@ -569,6 +588,7 @@ struct PublicMusicbillDetail: Decodable, Hashable, Identifiable { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) cover = try container.decodeIfPresent(String.self, forKey: .cover) ?? "" + coverThumbnail = try container.decodeIfPresent(String.self, forKey: .coverThumbnail) name = try container.decode(String.self, forKey: .name) isPublic = try container.decodeFlexibleBool(forKey: .isPublic) user = try container.decode(MusicbillUser.self, forKey: .user) @@ -778,6 +798,7 @@ struct ExplorationPublicMusicbillItem: Decodable, Hashable, Identifiable { let id: String let name: String var cover: String + var coverThumbnail: String? var user: MusicbillUser func asSearchItem() -> PublicMusicbillSearchItem { @@ -785,6 +806,7 @@ struct ExplorationPublicMusicbillItem: Decodable, Hashable, Identifiable { id: id, name: name, cover: cover, + coverThumbnail: coverThumbnail, musicCount: 0, collectionCount: 0, user: user @@ -795,6 +817,7 @@ struct ExplorationPublicMusicbillItem: Decodable, Hashable, Identifiable { case id case name case cover + case coverThumbnail case user } @@ -803,6 +826,7 @@ struct ExplorationPublicMusicbillItem: Decodable, Hashable, Identifiable { id = try container.decode(String.self, forKey: .id) name = try container.decode(String.self, forKey: .name) cover = try container.decodeIfPresent(String.self, forKey: .cover) ?? "" + coverThumbnail = try container.decodeIfPresent(String.self, forKey: .coverThumbnail) user = try container.decode(MusicbillUser.self, forKey: .user) } } diff --git a/apps/apple/Cicada/Services/AudioPlayerController.swift b/apps/apple/Cicada/Services/AudioPlayerController.swift index 48c49cf98..91d99c853 100644 --- a/apps/apple/Cicada/Services/AudioPlayerController.swift +++ b/apps/apple/Cicada/Services/AudioPlayerController.swift @@ -1,5 +1,12 @@ @preconcurrency import AVFoundation import Foundation +#if canImport(MediaPlayer) +import MediaPlayer +#endif +#if canImport(MediaPlayer) && os(iOS) +import UIKit +private typealias SystemArtworkImage = UIImage +#endif @MainActor final class AudioPlayerController: ObservableObject { @@ -30,9 +37,13 @@ final class AudioPlayerController: ObservableObject { private var client: CicadaAPIClient? private var activeRecord: ActivePlaybackRecord? private var pendingRadioAutoplay = false + #if canImport(MediaPlayer) && os(iOS) + private var systemArtworkTask: Task? + #endif init() { configureAudioSession() + configureSystemMediaRemoteCommands() installTimeObserver() } @@ -44,6 +55,13 @@ final class AudioPlayerController: ObservableObject { if let endObserver { NotificationCenter.default.removeObserver(endObserver) } + #if canImport(MediaPlayer) && os(iOS) + systemArtworkTask?.cancel() + #endif + #if canImport(MediaPlayer) + removeSystemMediaRemoteCommands() + clearSystemNowPlayingInfo() + #endif } } @@ -104,12 +122,14 @@ final class AudioPlayerController: ObservableObject { player.pause() isPlaying = false uploadActiveRecord() + updateSystemNowPlayingInfo() } func resume() { guard currentMusic != nil else { return } player.play() isPlaying = true + updateSystemNowPlayingInfo() } func next() { @@ -154,6 +174,7 @@ final class AudioPlayerController: ObservableObject { toleranceAfter: .zero ) currentTime = clampedSeconds + updateSystemNowPlayingInfo() } func stop() { @@ -169,6 +190,7 @@ final class AudioPlayerController: ObservableObject { isPlaying = false activeRecord = nil exitRadioMode() + clearSystemNowPlayingInfo() } private func exitRadioMode() { @@ -201,6 +223,7 @@ final class AudioPlayerController: ObservableObject { player.play() isPlaying = true + updateSystemNowPlayingInfo(loadArtwork: true) if isRadioMode { onRadioAdvance?() @@ -248,6 +271,7 @@ final class AudioPlayerController: ObservableObject { } syncActiveRecord() + updateSystemNowPlayingInfo() } private func handlePlaybackEnded() { @@ -266,6 +290,7 @@ final class AudioPlayerController: ObservableObject { } else { isPlaying = false } + updateSystemNowPlayingInfo() } private func syncActiveRecord() { @@ -317,6 +342,175 @@ final class AudioPlayerController: ObservableObject { return (clamped * 20).rounded() / 20 } + #if canImport(MediaPlayer) + private func configureSystemMediaRemoteCommands() { + let commandCenter = MPRemoteCommandCenter.shared() + + commandCenter.playCommand.addTarget { [weak self] _ in + Task { @MainActor in + self?.resume() + } + return .success + } + + commandCenter.pauseCommand.addTarget { [weak self] _ in + Task { @MainActor in + self?.pause() + } + return .success + } + + commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in + Task { @MainActor in + self?.togglePlayback() + } + return .success + } + + commandCenter.nextTrackCommand.addTarget { [weak self] _ in + Task { @MainActor in + self?.next() + } + return .success + } + + commandCenter.previousTrackCommand.addTarget { [weak self] _ in + Task { @MainActor in + self?.previous() + } + return .success + } + + commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in + guard let event = event as? MPChangePlaybackPositionCommandEvent else { + return .commandFailed + } + + Task { @MainActor in + self?.seek(to: event.positionTime) + } + return .success + } + + commandCenter.stopCommand.isEnabled = false + updateSystemCommandAvailability() + } + + private func removeSystemMediaRemoteCommands() { + let commandCenter = MPRemoteCommandCenter.shared() + commandCenter.playCommand.removeTarget(nil) + commandCenter.pauseCommand.removeTarget(nil) + commandCenter.togglePlayPauseCommand.removeTarget(nil) + commandCenter.nextTrackCommand.removeTarget(nil) + commandCenter.previousTrackCommand.removeTarget(nil) + commandCenter.changePlaybackPositionCommand.removeTarget(nil) + } + + private func updateSystemCommandAvailability() { + let hasCurrentMusic = currentMusic != nil + let commandCenter = MPRemoteCommandCenter.shared() + + commandCenter.playCommand.isEnabled = hasCurrentMusic && !isPlaying + commandCenter.pauseCommand.isEnabled = hasCurrentMusic && isPlaying + commandCenter.togglePlayPauseCommand.isEnabled = hasCurrentMusic + commandCenter.nextTrackCommand.isEnabled = hasCurrentMusic && (queue.count > 1 || isRadioMode) + commandCenter.previousTrackCommand.isEnabled = hasCurrentMusic + commandCenter.changePlaybackPositionCommand.isEnabled = hasCurrentMusic && duration > 0 + } + + private func updateSystemNowPlayingInfo(loadArtwork: Bool = false) { + guard let music = currentMusic else { + clearSystemNowPlayingInfo() + return + } + + // Keep system media surfaces authoritative for title, timing, queue + // position, and playback rate; artwork is loaded separately. + var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] + info[MPMediaItemPropertyTitle] = music.name + info[MPMediaItemPropertyArtist] = music.performerLine + info[MPMediaItemPropertyPlaybackDuration] = duration + info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime + info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0 + info[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0 + info[MPNowPlayingInfoPropertyPlaybackQueueIndex] = max(0, currentQueueIndex) + info[MPNowPlayingInfoPropertyPlaybackQueueCount] = max(1, queue.count) + info[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.audio.rawValue + info[MPNowPlayingInfoPropertyExternalContentIdentifier] = music.id + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + MPNowPlayingInfoCenter.default().playbackState = isPlaying ? .playing : .paused + + updateSystemCommandAvailability() + + #if os(iOS) + if loadArtwork { + loadSystemArtwork(for: music) + } + #endif + } + + #if os(iOS) + private func loadSystemArtwork(for music: Music) { + systemArtworkTask?.cancel() + + let artworkURLString = music.coverThumbnail?.isEmpty == false ? music.coverThumbnail : music.cover + guard + let artworkURLString, + let artworkURL = URL(string: artworkURLString) + else { + removeSystemArtwork() + return + } + + let musicID = music.id + systemArtworkTask = Task { [weak self, artworkURL, musicID] in + guard + let (data, _) = try? await URLSession.shared.data(from: artworkURL), + !Task.isCancelled + else { + return + } + + self?.applySystemArtwork(data: data, musicID: musicID) + } + } + + private func applySystemArtwork(data: Data, musicID: Music.ID) { + guard + currentMusic?.id == musicID, + let image = SystemArtworkImage(data: data) + else { + return + } + + var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] + info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in + image + } + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + } + + private func removeSystemArtwork() { + var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] + info.removeValue(forKey: MPMediaItemPropertyArtwork) + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + } + #endif + + private func clearSystemNowPlayingInfo() { + #if os(iOS) + systemArtworkTask?.cancel() + #endif + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + MPNowPlayingInfoCenter.default().playbackState = .stopped + updateSystemCommandAvailability() + } + #else + private func configureSystemMediaRemoteCommands() {} + private func updateSystemNowPlayingInfo(loadArtwork: Bool = false) {} + private func clearSystemNowPlayingInfo() {} + #endif + private func configureAudioSession() { #if os(iOS) try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) diff --git a/apps/apple/Cicada/Services/CicadaAPIClient.swift b/apps/apple/Cicada/Services/CicadaAPIClient.swift index 7acc7ca67..608e0f72c 100644 --- a/apps/apple/Cicada/Services/CicadaAPIClient.swift +++ b/apps/apple/Cicada/Services/CicadaAPIClient.swift @@ -137,6 +137,7 @@ struct CicadaAPIClient: Sendable { user.avatar = absoluteURLString(user.avatar) for index in user.musicbillList.indices { user.musicbillList[index].cover = absoluteURLString(user.musicbillList[index].cover) + user.musicbillList[index].coverThumbnail = absoluteURLString(user.musicbillList[index].coverThumbnail) } return user } @@ -146,6 +147,7 @@ struct CicadaAPIClient: Sendable { for index in musicbills.indices { var musicbill = musicbills[index] musicbill.cover = absoluteURLString(musicbill.cover) + musicbill.coverThumbnail = absoluteURLString(musicbill.coverThumbnail) normalizeUserAssets(&musicbill.owner, sharedUsers: &musicbill.sharedUserList) musicbills[index] = musicbill } @@ -158,6 +160,7 @@ struct CicadaAPIClient: Sendable { query: ["id": id] ) musicbill.cover = absoluteURLString(musicbill.cover) + musicbill.coverThumbnail = absoluteURLString(musicbill.coverThumbnail) var owner = musicbill.owner var sharedUserList = musicbill.sharedUserList normalizeUserAssets(&owner, sharedUsers: &sharedUserList) @@ -180,6 +183,7 @@ struct CicadaAPIClient: Sendable { ) for index in result.musicbillList.indices { result.musicbillList[index].cover = absoluteURLString(result.musicbillList[index].cover) + result.musicbillList[index].coverThumbnail = absoluteURLString(result.musicbillList[index].coverThumbnail) result.musicbillList[index].user.avatar = absoluteURLString(result.musicbillList[index].user.avatar) } return result @@ -191,6 +195,7 @@ struct CicadaAPIClient: Sendable { query: ["id": id] ) musicbill.cover = absoluteURLString(musicbill.cover) + musicbill.coverThumbnail = absoluteURLString(musicbill.coverThumbnail) musicbill.user.avatar = absoluteURLString(musicbill.user.avatar) normalizeMusicListAssets(&musicbill.musicList) return musicbill @@ -211,6 +216,7 @@ struct CicadaAPIClient: Sendable { ) for index in result.collectionList.indices { result.collectionList[index].cover = absoluteURLString(result.collectionList[index].cover) + result.collectionList[index].coverThumbnail = absoluteURLString(result.collectionList[index].coverThumbnail) result.collectionList[index].user.avatar = absoluteURLString(result.collectionList[index].user.avatar) } return result @@ -361,6 +367,7 @@ struct CicadaAPIClient: Sendable { normalizeArtistSearchItems(&music.composers) for index in music.relatedPublicMusicbillList.indices { music.relatedPublicMusicbillList[index].cover = absoluteURLString(music.relatedPublicMusicbillList[index].cover) + music.relatedPublicMusicbillList[index].coverThumbnail = absoluteURLString(music.relatedPublicMusicbillList[index].coverThumbnail) music.relatedPublicMusicbillList[index].user.avatar = absoluteURLString(music.relatedPublicMusicbillList[index].user.avatar) } return music @@ -686,6 +693,7 @@ struct CicadaAPIClient: Sendable { private func normalizeExplorationMusicbills(_ list: inout [ExplorationPublicMusicbillItem]) { for index in list.indices { list[index].cover = absoluteURLString(list[index].cover) + list[index].coverThumbnail = absoluteURLString(list[index].coverThumbnail) list[index].user.avatar = absoluteURLString(list[index].user.avatar) } } diff --git a/apps/apple/Cicada/Support/AppColors.swift b/apps/apple/Cicada/Support/AppColors.swift index 403445bb4..27861de60 100644 --- a/apps/apple/Cicada/Support/AppColors.swift +++ b/apps/apple/Cicada/Support/AppColors.swift @@ -7,6 +7,17 @@ import UIKit #endif extension Color { + static let cicadaPrimary = Color(red: 44.0 / 255.0, green: 182.0 / 255.0, blue: 125.0 / 255.0) + + static var cicadaSidebarBackground: Color { + // Mirrors the PWA `--background-color-level-one` token: rgb(44 182 125 / 0.06). + cicadaPrimary.opacity(0.06) + } + + static var cicadaSidebarVoidBackground: Color { + Color.white + } + static var cicadaBackground: Color { #if os(macOS) Color(nsColor: .windowBackgroundColor) diff --git a/apps/apple/Cicada/Support/ArtworkView.swift b/apps/apple/Cicada/Support/ArtworkView.swift index 14abf7387..9a70abe14 100644 --- a/apps/apple/Cicada/Support/ArtworkView.swift +++ b/apps/apple/Cicada/Support/ArtworkView.swift @@ -1,29 +1,39 @@ import SwiftUI +#if os(macOS) +import AppKit +#else +import UIKit +#endif struct ArtworkView: View { let urlString: String? + var placeholderURLString: String? = nil let systemImage: String let size: CGFloat var body: some View { Group { - if let url = imageURL { + if let inlineImage { + inlineImage + .resizable() + .scaledToFill() + } else if let url = imageURL { AsyncImage(url: url) { phase in switch phase { case .empty: - ProgressView() + loadingPlaceholder case .success(let image): image .resizable() .scaledToFill() case .failure: - placeholder + fallbackPlaceholder @unknown default: - placeholder + fallbackPlaceholder } } } else { - placeholder + fallbackPlaceholder } } .frame(width: size, height: size) @@ -41,7 +51,52 @@ struct ArtworkView: View { return URL(string: urlString) } - private var placeholder: some View { + @ViewBuilder + private var loadingPlaceholder: some View { + if let placeholderImage { + placeholderImage + .resizable() + .scaledToFill() + } else { + ProgressView() + } + } + + private var inlineImage: Image? { + imageFromDataURL(urlString) + } + + private var placeholderImage: Image? { + imageFromDataURL(placeholderURLString) + } + + private func imageFromDataURL(_ value: String?) -> Image? { + guard + let value, + let commaIndex = value.firstIndex(of: ","), + value[.. ServerRecord { + ServerRecord( + version: "3.6.0", + hostname: "music.local", + imageFileMaxSize: nil, + audioFileMaxSize: nil, + videoFileMaxSize: nil, + origin: "https://music.local", + users: users, + selectedUserID: selectedUserID + ) +} + +private func makeUser( + id: String, + nickname: String = "User" +) -> ServerUserRecord { + ServerUserRecord( + id: id, + username: id, + avatar: "", + nickname: nickname, + joinTimestamp: 1_700_000_000, + admin: false, + musicbillOrders: [], + twoFAEnabled: false, + token: "token-\(id)", + sessionID: "session-\(id)" + ) +} diff --git a/apps/apple/CicadaTests/ServerSetupStoreTests.swift b/apps/apple/CicadaTests/ServerSetupStoreTests.swift new file mode 100644 index 000000000..de44a3a8e --- /dev/null +++ b/apps/apple/CicadaTests/ServerSetupStoreTests.swift @@ -0,0 +1,207 @@ +import XCTest +@testable import Cicada + +@MainActor +final class ServerSetupStoreTests: XCTestCase { + func testConnectDraftOriginNormalizesAndPersistsServer() async throws { + let storage = try makeStorage() + defer { storage.removePersistentDomain() } + + let store = ServerSetupStore( + client: .stub(metadata: .test(version: "3.6.0", hostname: "music.local")), + storage: storage.defaults + ) + store.draftOrigin = " Music.Local " + + await store.connectDraftOrigin() + + XCTAssertNil(store.errorMessage) + XCTAssertEqual(store.draftOrigin, "https://music.local") + XCTAssertEqual(store.selectedServerOrigin, "https://music.local") + XCTAssertEqual(store.savedServers.map(\.origin), ["https://music.local"]) + XCTAssertEqual(store.selectedServer?.version, "3.6.0") + + let reloaded = ServerSetupStore(client: .stub(), storage: storage.defaults) + XCTAssertEqual(reloaded.selectedServerOrigin, "https://music.local") + XCTAssertEqual(reloaded.savedServers.first?.hostname, "music.local") + } + + func testConnectDraftOriginRejectsPathsBeforeFetchingMetadata() async throws { + let storage = try makeStorage() + defer { storage.removePersistentDomain() } + + let store = ServerSetupStore( + client: ServerMetadataClient { _ in + throw ServerMetadataClientError.missingPayload + }, + storage: storage.defaults + ) + store.draftOrigin = "https://music.local/app" + + await store.connectDraftOrigin() + + XCTAssertEqual( + store.errorMessage, + ServerSetupError.pathNotSupported.errorDescription + ) + XCTAssertTrue(store.savedServers.isEmpty) + } + + func testSelectUserAndRemoveSelectedUserPersistChanges() throws { + let storage = try makeStorage() + defer { storage.removePersistentDomain() } + + persist( + ServerSnapshot( + savedServers: [ + .test( + origin: "https://music.local", + users: [ + .test(id: "u1", nickname: "One"), + .test(id: "u2", nickname: "Two"), + ], + selectedUserID: "u1" + ), + ], + selectedServerOrigin: "https://music.local" + ), + in: storage.defaults + ) + let store = ServerSetupStore(client: .stub(), storage: storage.defaults) + + store.selectUser(.test(id: "u2", nickname: "Two")) + XCTAssertEqual(store.selectedUser?.id, "u2") + + store.removeSelectedUser(message: "Session expired.") + XCTAssertEqual(store.authenticationMessage, "Session expired.") + XCTAssertNil(store.selectedUser) + XCTAssertEqual(store.selectedServer?.users.map(\.id), ["u1"]) + + let reloaded = ServerSetupStore(client: .stub(), storage: storage.defaults) + XCTAssertNil(reloaded.selectedUser) + XCTAssertEqual(reloaded.selectedServer?.users.map(\.id), ["u1"]) + } + + func testRefreshSelectedServerMetadataUpdatesExistingRecord() async throws { + let storage = try makeStorage() + defer { storage.removePersistentDomain() } + + persist( + ServerSnapshot( + savedServers: [ + .test( + version: "3.5.0", + hostname: "old.local", + origin: "https://music.local" + ), + ], + selectedServerOrigin: "https://music.local" + ), + in: storage.defaults + ) + let store = ServerSetupStore( + client: .stub( + metadata: .test( + version: "3.6.0", + hostname: "new.local", + imageFileMaxSize: 100, + audioFileMaxSize: 200, + videoFileMaxSize: 300 + ) + ), + storage: storage.defaults + ) + + await store.refreshSelectedServerMetadata() + + XCTAssertNil(store.selectedServerMetadataError) + XCTAssertEqual(store.selectedServer?.version, "3.6.0") + XCTAssertEqual(store.selectedServer?.hostname, "new.local") + XCTAssertEqual(store.selectedServer?.imageFileMaxSize, 100) + XCTAssertEqual(store.selectedServer?.audioFileMaxSize, 200) + XCTAssertEqual(store.selectedServer?.videoFileMaxSize, 300) + } + + private func makeStorage() throws -> TestStorage { + let suiteName = "io.github.manyone.cicada.apple.tests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + return TestStorage(suiteName: suiteName, defaults: defaults) + } + + private func persist(_ snapshot: ServerSnapshot, in defaults: UserDefaults) { + let data = try! JSONEncoder().encode(snapshot) + defaults.set(data, forKey: ServerSetupStore.storageKey) + } +} + +private struct TestStorage { + let suiteName: String + let defaults: UserDefaults + + func removePersistentDomain() { + defaults.removePersistentDomain(forName: suiteName) + } +} + +private extension ServerMetadataClient { + static func stub(metadata: ServerMetadata = .test()) -> ServerMetadataClient { + ServerMetadataClient { _ in metadata } + } +} + +private extension ServerMetadata { + static func test( + version: String = "3.6.0", + hostname: String = "music.local", + imageFileMaxSize: Int? = nil, + audioFileMaxSize: Int? = nil, + videoFileMaxSize: Int? = nil + ) -> ServerMetadata { + ServerMetadata( + version: version, + hostname: hostname, + imageFileMaxSize: imageFileMaxSize, + audioFileMaxSize: audioFileMaxSize, + videoFileMaxSize: videoFileMaxSize + ) + } +} + +private extension ServerRecord { + static func test( + version: String = "3.6.0", + hostname: String = "music.local", + origin: String = "https://music.local", + users: [ServerUserRecord] = [], + selectedUserID: String? = nil + ) -> ServerRecord { + ServerRecord( + version: version, + hostname: hostname, + imageFileMaxSize: nil, + audioFileMaxSize: nil, + videoFileMaxSize: nil, + origin: origin, + users: users, + selectedUserID: selectedUserID + ) + } +} + +private extension ServerUserRecord { + static func test(id: String, nickname: String = "User") -> ServerUserRecord { + ServerUserRecord( + id: id, + username: id, + avatar: "", + nickname: nickname, + joinTimestamp: 1_700_000_000, + admin: false, + musicbillOrders: [], + twoFAEnabled: false, + token: "token-\(id)", + sessionID: "session-\(id)" + ) + } +} diff --git a/apps/apple/project.yml b/apps/apple/project.yml index 58bdfe722..2143a6d35 100644 --- a/apps/apple/project.yml +++ b/apps/apple/project.yml @@ -28,6 +28,8 @@ targets: UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: true UIApplicationSupportsIndirectInputEvents: true + UIBackgroundModes: + - audio NSAppTransportSecurity: NSAllowsArbitraryLoads: true NSLocalNetworkUsageDescription: Cicada connects to your selected music server on the local network. @@ -58,6 +60,9 @@ targets: ENABLE_PREVIEWS: YES LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks" LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]: "$(inherited) @executable_path/../Frameworks" + configs: + Debug: + CODE_SIGNING_ALLOWED: NO postBuildScripts: - name: Resolve Cicada Version shell: /bin/bash @@ -112,11 +117,32 @@ targets: } > "${version_header}" echo "Resolved CicadaVersion=${app_version}" + CicadaTests: + type: bundle.unit-test + supportedDestinations: + - iOS + - macOS + sources: + - path: CicadaTests + dependencies: + - target: Cicada + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: io.github.manyone.cicada.apple.tests + SWIFT_VERSION: 6.0 + GENERATE_INFOPLIST_FILE: YES + TEST_HOST[sdk=macosx*]: "$(BUILT_PRODUCTS_DIR)/Cicada.app/Contents/MacOS/Cicada" + BUNDLE_LOADER[sdk=macosx*]: "$(TEST_HOST)" schemes: Cicada: build: targets: Cicada: all + CicadaTests: [test] + test: + config: Debug + targets: + - CicadaTests run: config: Debug analyze: diff --git a/apps/cli/.gitignore b/apps/cli/.gitignore index 22c045e55..8835322e3 100644 --- a/apps/cli/.gitignore +++ b/apps/cli/.gitignore @@ -1,2 +1,3 @@ tmp/ pwa/dist/ +storybook/static/ diff --git a/apps/cli/cmd/start.go b/apps/cli/cmd/start.go index 7a4790280..b2428c79a 100644 --- a/apps/cli/cmd/start.go +++ b/apps/cli/cmd/start.go @@ -7,6 +7,7 @@ import ( "cicada/internal/server" "cicada/internal/store" "cicada/pwa" + "cicada/storybook" "fmt" "log" "net/http" @@ -116,6 +117,7 @@ func runStart(cmd *cobra.Command, args []string) error { r := server.NewServer() pwa.Register(r) + storybook.Register(r) addr := fmt.Sprintf(":%d", cfg.Port) log.Printf("cicada listening on %s", addr) diff --git a/apps/cli/internal/api/handler/music.go b/apps/cli/internal/api/handler/music.go index 6746f9775..338fbbc04 100644 --- a/apps/cli/internal/api/handler/music.go +++ b/apps/cli/internal/api/handler/music.go @@ -775,10 +775,11 @@ func GetExploration(c *gin.Context) { Name string } type mbRow struct { - ID string - Name string - Cover string - UserID string + ID string + Name string + Cover string + CoverThumbnail string + UserID string } queryMusicRows := func(sql string, args ...any) []musicRow { @@ -818,7 +819,7 @@ func GetExploration(c *gin.Context) { var out []mbRow for rows.Next() { r := mbRow{} - rows.Scan(&r.ID, &r.Name, &r.Cover, &r.UserID) + rows.Scan(&r.ID, &r.Name, &r.Cover, &r.CoverThumbnail, &r.UserID) out = append(out, r) } return out @@ -831,7 +832,7 @@ func GetExploration(c *gin.Context) { `SELECT id,name FROM artist ORDER BY random() LIMIT ?`, quality, ) mbRows := queryMbRows( - `SELECT id,name,cover,userId FROM musicbill WHERE public=1 AND cover!='' ORDER BY random() LIMIT ?`, quality, + `SELECT id,name,cover,coverThumbnail,userId FROM musicbill WHERE public=1 AND cover!='' ORDER BY random() LIMIT ?`, quality, ) // 最近添加: 按 createTimestamp 倒序取最新条目, 让发现页能呈现新入库内容。 recentMusicRows := queryMusicRows( @@ -841,7 +842,7 @@ func GetExploration(c *gin.Context) { `SELECT id,name FROM artist ORDER BY createTimestamp DESC LIMIT ?`, recentLimit, ) recentMbRows := queryMbRows( - `SELECT id,name,cover,userId FROM musicbill WHERE public=1 AND cover!='' ORDER BY createTimestamp DESC LIMIT ?`, recentLimit, + `SELECT id,name,cover,coverThumbnail,userId FROM musicbill WHERE public=1 AND cover!='' ORDER BY createTimestamp DESC LIMIT ?`, recentLimit, ) // 合并随机和最近添加列表的 ID, 用一次查询拉齐关联数据 (表演者、图片、用户), 减少数据库往返。 @@ -939,10 +940,11 @@ func GetExploration(c *gin.Context) { list := make([]gin.H, len(rows)) for i, mb := range rows { list[i] = gin.H{ - "id": mb.ID, - "name": mb.Name, - "cover": config.AssetPublicURL(mb.Cover, config.AssetTypeMusicbillCover), - "user": gin.H{"id": mb.UserID, "nickname": userMap[mb.UserID]}, + "id": mb.ID, + "name": mb.Name, + "cover": config.AssetPublicURL(mb.Cover, config.AssetTypeMusicbillCover), + "coverThumbnail": mb.CoverThumbnail, + "user": gin.H{"id": mb.UserID, "nickname": userMap[mb.UserID]}, } } return list @@ -963,13 +965,13 @@ func GetExploration(c *gin.Context) { func relatedPublicMusicbillItems(musicID string) []gin.H { // 相关公开乐单只在音乐抽屉末尾展示少量卡片, 在数据库层随机抽样避免返回全量后再裁剪。 rows, err := store.DB().Query( - `SELECT mb.id,mb.name,mb.cover,mb.userId,u.nickname,u.avatar,COUNT(all_mm.id) + `SELECT mb.id,mb.name,mb.cover,mb.coverThumbnail,mb.userId,u.nickname,u.avatar,COUNT(all_mm.id) FROM musicbill mb JOIN musicbill_music matched_mm ON matched_mm.musicbillId=mb.id AND matched_mm.musicId=? JOIN user u ON u.id=mb.userId LEFT JOIN musicbill_music all_mm ON all_mm.musicbillId=mb.id WHERE mb.public=1 - GROUP BY mb.id,mb.name,mb.cover,mb.userId,u.nickname,u.avatar + GROUP BY mb.id,mb.name,mb.cover,mb.coverThumbnail,mb.userId,u.nickname,u.avatar ORDER BY random() LIMIT 5`, musicID, ) @@ -980,16 +982,17 @@ func relatedPublicMusicbillItems(musicID string) []gin.H { list := []gin.H{} for rows.Next() { - var id, name, cover, userID, nickname, avatar string + var id, name, cover, coverThumbnail, userID, nickname, avatar string var musicCount int - if err := rows.Scan(&id, &name, &cover, &userID, &nickname, &avatar, &musicCount); err != nil { + if err := rows.Scan(&id, &name, &cover, &coverThumbnail, &userID, &nickname, &avatar, &musicCount); err != nil { continue } list = append(list, gin.H{ - "id": id, - "name": name, - "cover": config.AssetPublicURL(cover, config.AssetTypeMusicbillCover), - "musicCount": musicCount, + "id": id, + "name": name, + "cover": config.AssetPublicURL(cover, config.AssetTypeMusicbillCover), + "coverThumbnail": coverThumbnail, + "musicCount": musicCount, "user": gin.H{ "id": userID, "nickname": nickname, diff --git a/apps/cli/internal/api/handler/musicbill.go b/apps/cli/internal/api/handler/musicbill.go index 038ae1f93..1a0ee8344 100644 --- a/apps/cli/internal/api/handler/musicbill.go +++ b/apps/cli/internal/api/handler/musicbill.go @@ -22,7 +22,7 @@ func GetMusicbillList(c *gin.Context) { } // shared musicbills accepted sharedRows, _ := store.DB().Query( - `SELECT mb.id,mb.userId,mb.cover,mb.name,mb.public,mb.createTimestamp,owner.nickname,owner.avatar + `SELECT mb.id,mb.userId,mb.cover,mb.coverThumbnail,mb.name,mb.public,mb.createTimestamp,owner.nickname,owner.avatar FROM shared_musicbill smb JOIN musicbill mb ON smb.musicbillId=mb.id JOIN user owner ON mb.userId=owner.id @@ -34,7 +34,7 @@ func GetMusicbillList(c *gin.Context) { for sharedRows.Next() { var mb store.Musicbill var ownerNick, ownerAvatar string - sharedRows.Scan(&mb.ID, &mb.UserID, &mb.Cover, &mb.Name, &mb.Public, &mb.CreateTimestamp, &ownerNick, &ownerAvatar) + sharedRows.Scan(&mb.ID, &mb.UserID, &mb.Cover, &mb.CoverThumbnail, &mb.Name, &mb.Public, &mb.CreateTimestamp, &ownerNick, &ownerAvatar) shared = append(shared, mb) } } @@ -111,6 +111,7 @@ func GetMusicbillList(c *gin.Context) { "id": mb.ID, "name": mb.Name, "cover": config.AssetPublicURL(mb.Cover, config.AssetTypeMusicbillCover), + "coverThumbnail": mb.CoverThumbnail, "public": mb.Public == 1, "createTimestamp": mb.CreateTimestamp, "owner": ownerMap[mb.UserID], @@ -239,6 +240,7 @@ func GetMusicbill(c *gin.Context) { "id": mb.ID, "name": mb.Name, "cover": config.AssetPublicURL(mb.Cover, config.AssetTypeMusicbillCover), + "coverThumbnail": mb.CoverThumbnail, "public": mb.Public == 1, "createTimestamp": mb.CreateTimestamp, "owner": gin.H{ @@ -339,7 +341,7 @@ func UpdateMusicbill(c *gin.Context) { api.Fail(c, apperr.AssetNotExisted) return } - store.UpdateMusicbill(body.ID, "cover", cover) + store.UpdateMusicbillCover(body.ID, cover, assetThumbnailDataURL(cover, config.AssetTypeMusicbillCover)) case "public": pub, ok := body.Value.(bool) if !ok { @@ -762,6 +764,7 @@ func GetPublicMusicbill(c *gin.Context) { "id": mb.ID, "name": mb.Name, "cover": config.AssetPublicURL(mb.Cover, config.AssetTypeMusicbillCover), + "coverThumbnail": mb.CoverThumbnail, "public": mb.Public == 1, "createTimestamp": mb.CreateTimestamp, "user": gin.H{ @@ -790,10 +793,11 @@ func SearchPublicMusicbill(c *gin.Context) { list := make([]gin.H, len(mbs)) for i, mb := range mbs { list[i] = gin.H{ - "id": mb.ID, - "name": mb.Name, - "cover": config.AssetPublicURL(mb.Cover, config.AssetTypeMusicbillCover), - "musicCount": mb.MusicCount, + "id": mb.ID, + "name": mb.Name, + "cover": config.AssetPublicURL(mb.Cover, config.AssetTypeMusicbillCover), + "coverThumbnail": mb.CoverThumbnail, + "musicCount": mb.MusicCount, "user": gin.H{ "id": mb.UserID, "nickname": mb.OwnerNickname, @@ -866,7 +870,7 @@ func GetPublicMusicbillCollectionList(c *gin.Context) { ).Scan(&total) // 收藏页直接展示乐单音乐数量,在列表查询里一起取出。 r, err := store.DB().Query( - `SELECT m.id,m.name,m.cover,m.userId, + `SELECT m.id,m.name,m.cover,m.coverThumbnail,m.userId, (SELECT COUNT(1) FROM musicbill_music mm WHERE mm.musicbillId=m.id) AS musicCount FROM public_musicbill_collection mc LEFT JOIN musicbill m ON m.id=mc.musicbillId @@ -882,14 +886,14 @@ func GetPublicMusicbillCollectionList(c *gin.Context) { defer r.Close() type row struct { - ID, Name, Cover, UserID string - MusicCount int + ID, Name, Cover, CoverThumbnail, UserID string + MusicCount int } var items []row sqlRows := r for sqlRows.Next() { var item row - sqlRows.Scan(&item.ID, &item.Name, &item.Cover, &item.UserID, &item.MusicCount) + sqlRows.Scan(&item.ID, &item.Name, &item.Cover, &item.CoverThumbnail, &item.UserID, &item.MusicCount) items = append(items, item) } @@ -921,11 +925,12 @@ func GetPublicMusicbillCollectionList(c *gin.Context) { list := make([]gin.H, len(items)) for i, item := range items { list[i] = gin.H{ - "id": item.ID, - "name": item.Name, - "cover": config.AssetPublicURL(item.Cover, config.AssetTypeMusicbillCover), - "musicCount": item.MusicCount, - "user": ownerMap[item.UserID], + "id": item.ID, + "name": item.Name, + "cover": config.AssetPublicURL(item.Cover, config.AssetTypeMusicbillCover), + "coverThumbnail": item.CoverThumbnail, + "musicCount": item.MusicCount, + "user": ownerMap[item.UserID], } } _ = rows @@ -941,7 +946,7 @@ func GetPublicMusicbillCollectionList(c *gin.Context) { u.ID, ).Scan(&total) r, err := store.DB().Query( - `SELECT m.id,m.name,m.cover,m.userId, + `SELECT m.id,m.name,m.cover,m.coverThumbnail,m.userId, (SELECT COUNT(1) FROM musicbill_music mm WHERE mm.musicbillId=m.id) AS musicCount FROM public_musicbill_collection mc LEFT JOIN musicbill m ON m.id=mc.musicbillId @@ -956,13 +961,13 @@ func GetPublicMusicbillCollectionList(c *gin.Context) { defer r.Close() type row struct { - ID, Name, Cover, UserID string - MusicCount int + ID, Name, Cover, CoverThumbnail, UserID string + MusicCount int } var items []row for r.Next() { var item row - r.Scan(&item.ID, &item.Name, &item.Cover, &item.UserID, &item.MusicCount) + r.Scan(&item.ID, &item.Name, &item.Cover, &item.CoverThumbnail, &item.UserID, &item.MusicCount) items = append(items, item) } @@ -993,11 +998,12 @@ func GetPublicMusicbillCollectionList(c *gin.Context) { list := make([]gin.H, len(items)) for i, item := range items { list[i] = gin.H{ - "id": item.ID, - "name": item.Name, - "cover": config.AssetPublicURL(item.Cover, config.AssetTypeMusicbillCover), - "musicCount": item.MusicCount, - "user": ownerMap[item.UserID], + "id": item.ID, + "name": item.Name, + "cover": config.AssetPublicURL(item.Cover, config.AssetTypeMusicbillCover), + "coverThumbnail": item.CoverThumbnail, + "musicCount": item.MusicCount, + "user": ownerMap[item.UserID], } } api.OK(c, gin.H{"total": total, "collectionList": list}) diff --git a/apps/cli/internal/api/handler/musicbill_update_test.go b/apps/cli/internal/api/handler/musicbill_update_test.go new file mode 100644 index 000000000..0ad28bb32 --- /dev/null +++ b/apps/cli/internal/api/handler/musicbill_update_test.go @@ -0,0 +1,86 @@ +package handler + +import ( + "bytes" + "cicada/internal/config" + "cicada/internal/store" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +func TestUpdateMusicbillCoverStoresThumbnail(t *testing.T) { + gin.SetMode(gin.TestMode) + if err := store.ResetForTests(); err != nil { + t.Fatalf("reset store: %v", err) + } + t.Cleanup(func() { + if err := store.ResetForTests(); err != nil { + t.Fatalf("cleanup store: %v", err) + } + }) + + config.Set(config.Config{ + Mode: config.ModeProduction, + Data: t.TempDir(), + Port: 8000, + }) + if err := store.Initialize(); err != nil { + t.Fatalf("initialize store: %v", err) + } + + now := time.Now().UnixMilli() + if _, err := store.DB().Exec( + `INSERT INTO user (id,username,password,nickname,joinTimestamp) VALUES (?,?,?,?,?)`, + "USER01", "creator", store.DoubleMD5("password"), "Creator", now, + ); err != nil { + t.Fatalf("insert user: %v", err) + } + if _, err := store.DB().Exec( + `INSERT INTO musicbill (id,userId,name,createTimestamp) VALUES (?,?,?,?)`, + "BILL01", "USER01", "Bill", now, + ); err != nil { + t.Fatalf("insert musicbill: %v", err) + } + coverDir, coverPath := config.AssetPath(config.AssetTypeMusicbillCover, "cover.jpg") + if err := os.MkdirAll(coverDir, 0755); err != nil { + t.Fatalf("mkdir cover dir: %v", err) + } + writeTestJPEG(t, coverPath) + + body := []byte(`{"id":"BILL01","key":"cover","value":"cover.jpg"}`) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPut, "/api/common/musicbill", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + c.Set("authed_user", &store.User{ID: "USER01"}) + + UpdateMusicbill(c) + + var resp struct { + Code string `json:"code"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Code != "success" { + t.Fatalf("unexpected code: %s body=%s", resp.Code, w.Body.String()) + } + + mb, err := store.GetMusicbillByID("BILL01") + if err != nil { + t.Fatalf("get musicbill: %v", err) + } + if mb.Cover != "cover.jpg" { + t.Fatalf("expected cover.jpg, got %q", mb.Cover) + } + if !strings.HasPrefix(mb.CoverThumbnail, "data:image/jpeg;base64,") { + t.Fatalf("expected cover thumbnail data URL, got %q", mb.CoverThumbnail) + } +} diff --git a/apps/cli/internal/api/handler/user.go b/apps/cli/internal/api/handler/user.go index e1784f161..80a624a43 100644 --- a/apps/cli/internal/api/handler/user.go +++ b/apps/cli/internal/api/handler/user.go @@ -181,13 +181,14 @@ func GetUser(c *gin.Context) { } type publicMusicbill struct { - ID string - Cover string - Name string - MusicCount int + ID string + Cover string + CoverThumbnail string + Name string + MusicCount int } rows, err := store.DB().Query( - `SELECT id,cover,name, + `SELECT id,cover,coverThumbnail,name, (SELECT COUNT(1) FROM musicbill_music WHERE musicbillId=musicbill.id) FROM musicbill WHERE userId=? AND public=1 @@ -203,7 +204,7 @@ func GetUser(c *gin.Context) { musicbills := []publicMusicbill{} for rows.Next() { mb := publicMusicbill{} - if err := rows.Scan(&mb.ID, &mb.Cover, &mb.Name, &mb.MusicCount); err != nil { + if err := rows.Scan(&mb.ID, &mb.Cover, &mb.CoverThumbnail, &mb.Name, &mb.MusicCount); err != nil { api.Fail(c, apperr.ServerError) return } @@ -217,10 +218,11 @@ func GetUser(c *gin.Context) { musicbillItems := make([]gin.H, len(musicbills)) for i, mb := range musicbills { musicbillItems[i] = gin.H{ - "id": mb.ID, - "cover": config.AssetPublicURL(mb.Cover, config.AssetTypeMusicbillCover), - "name": mb.Name, - "musicCount": mb.MusicCount, + "id": mb.ID, + "cover": config.AssetPublicURL(mb.Cover, config.AssetTypeMusicbillCover), + "coverThumbnail": mb.CoverThumbnail, + "name": mb.Name, + "musicCount": mb.MusicCount, } } diff --git a/apps/cli/internal/apidoc/openapi.go b/apps/cli/internal/apidoc/openapi.go index cdf4c3cc5..8bef9fe45 100644 --- a/apps/cli/internal/apidoc/openapi.go +++ b/apps/cli/internal/apidoc/openapi.go @@ -1780,10 +1780,11 @@ func publicUserExample() map[string]any { "username": "cicada", "musicbillList": []any{ map[string]any{ - "id": "musicbill-1", - "cover": "/asset/musicbill_cover/cover.jpg", - "name": "Favorites", - "musicCount": 12, + "id": "musicbill-1", + "cover": "/asset/musicbill_cover/cover.jpg", + "coverThumbnail": "data:image/jpeg;base64,...", + "name": "Favorites", + "musicCount": 12, }, }, } @@ -1791,12 +1792,13 @@ func publicUserExample() map[string]any { func publicUserMusicbillSchema() map[string]any { return objSchema( - []string{"id", "cover", "name", "musicCount"}, + []string{"id", "cover", "coverThumbnail", "name", "musicCount"}, map[string]any{ - "id": strSchema("Public musicbill ID.", "musicbill-1"), - "cover": strSchema("Cover path.", "/asset/musicbill_cover/cover.jpg"), - "name": strSchema("Musicbill name.", "Favorites"), - "musicCount": intSchema("Music count.", 12), + "id": strSchema("Public musicbill ID.", "musicbill-1"), + "cover": strSchema("Cover path.", "/asset/musicbill_cover/cover.jpg"), + "coverThumbnail": strSchema("Tiny cover placeholder data URL.", "data:image/jpeg;base64,..."), + "name": strSchema("Musicbill name.", "Favorites"), + "musicCount": intSchema("Music count.", 12), }, ) } @@ -1894,13 +1896,14 @@ func musicRelatedSchema() map[string]any { func relatedPublicMusicbillSchema() map[string]any { return objSchema( - []string{"id", "name", "cover", "musicCount", "user"}, + []string{"id", "name", "cover", "coverThumbnail", "musicCount", "user"}, map[string]any{ - "id": strSchema("Musicbill ID.", "musicbill-1"), - "name": strSchema("Musicbill name.", "Late Night"), - "cover": strSchema("Cover path.", "/asset/musicbill_cover/cover.jpg"), - "musicCount": intSchema("Music count.", 12), - "user": userBriefSchema(true), + "id": strSchema("Musicbill ID.", "musicbill-1"), + "name": strSchema("Musicbill name.", "Late Night"), + "cover": strSchema("Cover path.", "/asset/musicbill_cover/cover.jpg"), + "coverThumbnail": strSchema("Tiny cover placeholder data URL.", "data:image/jpeg;base64,..."), + "musicCount": intSchema("Music count.", 12), + "user": userBriefSchema(true), }, ) } @@ -2241,11 +2244,12 @@ func sharedUserSchema() map[string]any { func musicbillSummarySchema() map[string]any { return objSchema( - []string{"id", "name", "cover", "public", "createTimestamp", "owner", "sharedUserList"}, + []string{"id", "name", "cover", "coverThumbnail", "public", "createTimestamp", "owner", "sharedUserList"}, map[string]any{ "id": strSchema("Musicbill ID.", "musicbill-1"), "name": strSchema("Musicbill name.", "Late Night"), "cover": strSchema("Cover path.", "/asset/musicbill_cover/cover.jpg"), + "coverThumbnail": strSchema("Tiny cover placeholder data URL.", "data:image/jpeg;base64,..."), "public": boolSchema("Whether the musicbill is public.", true), "createTimestamp": intSchema("Creation timestamp in milliseconds.", 1710000000000), "owner": userBriefSchema(true), @@ -2259,6 +2263,7 @@ func musicbillSummaryExample() map[string]any { "id": "musicbill-1", "name": "Late Night", "cover": "/asset/musicbill_cover/cover.jpg", + "coverThumbnail": "data:image/jpeg;base64,...", "public": true, "createTimestamp": int64(1710000000000), "owner": map[string]any{"id": "1", "nickname": "Cicada", "avatar": "/asset/user_avatar/avatar.jpg"}, @@ -2268,11 +2273,12 @@ func musicbillSummaryExample() map[string]any { func musicbillDetailSchema() map[string]any { return objSchema( - []string{"id", "name", "cover", "public", "createTimestamp", "owner", "sharedUserList", "musicList"}, + []string{"id", "name", "cover", "coverThumbnail", "public", "createTimestamp", "owner", "sharedUserList", "musicList"}, map[string]any{ "id": strSchema("Musicbill ID.", "musicbill-1"), "name": strSchema("Musicbill name.", "Late Night"), "cover": strSchema("Cover path.", "/asset/musicbill_cover/cover.jpg"), + "coverThumbnail": strSchema("Tiny cover placeholder data URL.", "data:image/jpeg;base64,..."), "public": boolSchema("Whether the musicbill is public.", true), "createTimestamp": intSchema("Creation timestamp in milliseconds.", 1710000000000), "owner": userBriefSchema(true), @@ -2297,6 +2303,7 @@ func musicbillDetailExample() map[string]any { "id": "musicbill-1", "name": "Late Night", "cover": "/asset/musicbill_cover/cover.jpg", + "coverThumbnail": "data:image/jpeg;base64,...", "public": true, "createTimestamp": int64(1710000000000), "owner": map[string]any{"id": "1", "nickname": "Cicada", "avatar": "/asset/user_avatar/avatar.jpg"}, @@ -2353,11 +2360,12 @@ func invitationExample() map[string]any { func publicMusicbillDetailSchema() map[string]any { return objSchema( - []string{"id", "name", "cover", "createTimestamp", "user", "musicList", "collected"}, + []string{"id", "name", "cover", "coverThumbnail", "createTimestamp", "user", "musicList", "collected"}, map[string]any{ "id": strSchema("Musicbill ID.", "musicbill-1"), "name": strSchema("Musicbill name.", "Late Night"), "cover": strSchema("Cover path.", "/asset/musicbill_cover/cover.jpg"), + "coverThumbnail": strSchema("Tiny cover placeholder data URL.", "data:image/jpeg;base64,..."), "createTimestamp": intSchema("Creation timestamp in milliseconds.", 1710000000000), "user": userBriefSchema(true), "musicList": objArraySchema(map[string]any{ @@ -2379,6 +2387,7 @@ func publicMusicbillDetailExample() map[string]any { "id": "musicbill-1", "name": "Late Night", "cover": "/asset/musicbill_cover/cover.jpg", + "coverThumbnail": "data:image/jpeg;base64,...", "createTimestamp": int64(1710000000000), "user": map[string]any{"id": "1", "nickname": "Cicada", "avatar": "/asset/user_avatar/avatar.jpg"}, "musicList": []any{ @@ -2398,13 +2407,14 @@ func publicMusicbillDetailExample() map[string]any { func musicbillCardSchema() map[string]any { return objSchema( - []string{"id", "name", "cover", "musicCount", "user"}, + []string{"id", "name", "cover", "coverThumbnail", "musicCount", "user"}, map[string]any{ - "id": strSchema("Musicbill ID.", "musicbill-1"), - "name": strSchema("Musicbill name.", "Late Night"), - "cover": strSchema("Cover path.", "/asset/musicbill_cover/cover.jpg"), - "musicCount": intSchema("Music count.", 12), - "user": userBriefSchema(true), + "id": strSchema("Musicbill ID.", "musicbill-1"), + "name": strSchema("Musicbill name.", "Late Night"), + "cover": strSchema("Cover path.", "/asset/musicbill_cover/cover.jpg"), + "coverThumbnail": strSchema("Tiny cover placeholder data URL.", "data:image/jpeg;base64,..."), + "musicCount": intSchema("Music count.", 12), + "user": userBriefSchema(true), }, ) } @@ -2424,11 +2434,12 @@ func musicbillPageExample(listKey string) map[string]any { "total": 1, listKey: []any{ map[string]any{ - "id": "musicbill-1", - "name": "Late Night", - "cover": "/asset/musicbill_cover/cover.jpg", - "musicCount": 12, - "user": map[string]any{"id": "1", "nickname": "Cicada", "avatar": "/asset/user_avatar/avatar.jpg"}, + "id": "musicbill-1", + "name": "Late Night", + "cover": "/asset/musicbill_cover/cover.jpg", + "coverThumbnail": "data:image/jpeg;base64,...", + "musicCount": 12, + "user": map[string]any{"id": "1", "nickname": "Cicada", "avatar": "/asset/user_avatar/avatar.jpg"}, }, }, } @@ -2445,11 +2456,12 @@ func explorationSchema() map[string]any { "id": strSchema("Artist ID.", "artist-1"), "name": strSchema("Artist name.", "Aurora"), }) - musicbillItemSchema := objSchema([]string{"id", "name", "cover", "user"}, map[string]any{ - "id": strSchema("Musicbill ID.", "musicbill-1"), - "name": strSchema("Musicbill name.", "Late Night"), - "cover": strSchema("Cover path.", "/asset/musicbill_cover/cover.jpg"), - "user": userBriefSchema(false), + musicbillItemSchema := objSchema([]string{"id", "name", "cover", "coverThumbnail", "user"}, map[string]any{ + "id": strSchema("Musicbill ID.", "musicbill-1"), + "name": strSchema("Musicbill name.", "Late Night"), + "cover": strSchema("Cover path.", "/asset/musicbill_cover/cover.jpg"), + "coverThumbnail": strSchema("Tiny cover placeholder data URL.", "data:image/jpeg;base64,..."), + "user": userBriefSchema(false), }) return objSchema( []string{ @@ -2480,7 +2492,13 @@ func explorationExample() map[string]any { map[string]any{"id": "artist-1", "name": "Aurora"}, } musicbillExample := []any{ - map[string]any{"id": "musicbill-1", "name": "Late Night", "cover": "/asset/musicbill_cover/cover.jpg", "user": map[string]any{"id": "1", "nickname": "Cicada"}}, + map[string]any{ + "id": "musicbill-1", + "name": "Late Night", + "cover": "/asset/musicbill_cover/cover.jpg", + "coverThumbnail": "data:image/jpeg;base64,...", + "user": map[string]any{"id": "1", "nickname": "Cicada"}, + }, } return map[string]any{ "musicList": musicExample, diff --git a/apps/cli/internal/scheduler/scheduler.go b/apps/cli/internal/scheduler/scheduler.go index 40854c1af..35e572a8b 100644 --- a/apps/cli/internal/scheduler/scheduler.go +++ b/apps/cli/internal/scheduler/scheduler.go @@ -40,7 +40,6 @@ func Start() { {"remove_unlinked_asset", removeUnlinkedAsset}, {"clean_music_transcode_cache", cleanMusicTranscodeCache}, {"pretranscode_music", pretranscodeMusic}, - {"decrease_music_heat", decreaseMusicHeat}, {"remove_outdated_shared_invitation", removeOutdatedSharedInvitation}, {"remove_outdated_auth_session", removeOutdatedAuthSession}, {"clean_outdated_file", cleanOutdatedFile}, @@ -411,18 +410,6 @@ func pretranscodeMusic() (schedulerJobResult, error) { }, errors.Join(errs...) } -func decreaseMusicHeat() (schedulerJobResult, error) { - updated, err := execRowsAffected( - `UPDATE music - SET heat = CASE WHEN heat > 0 THEN heat - 1 ELSE 0 END - WHERE heat != 0`, - ) - return schedulerJobResult{ - Summary: fmt.Sprintf("decreased heat for %d music rows", updated), - Metrics: map[string]int64{"updated_music_heat_rows": updated}, - }, err -} - // removeOutdatedSharedInvitation removes unanswered shared musicbill invitations older than 3 days. func removeOutdatedSharedInvitation() (schedulerJobResult, error) { threshold := time.Now().Add(-3 * 24 * time.Hour).UnixMilli() diff --git a/apps/cli/internal/scheduler/scheduler_test.go b/apps/cli/internal/scheduler/scheduler_test.go index cd7b34a8c..b81e6d20f 100644 --- a/apps/cli/internal/scheduler/scheduler_test.go +++ b/apps/cli/internal/scheduler/scheduler_test.go @@ -443,59 +443,6 @@ func TestRemoveUnlinkedAssetDeletesUnreferencedFiles(t *testing.T) { } } -func TestDecreaseMusicHeatDecreasesDailyWithoutGoingBelowZero(t *testing.T) { - if err := store.ResetForTests(); err != nil { - t.Fatalf("reset store: %v", err) - } - t.Cleanup(func() { - if err := store.ResetForTests(); err != nil { - t.Fatalf("cleanup store: %v", err) - } - }) - - config.Set(config.Config{ - Mode: config.ModeProduction, - Data: t.TempDir(), - Port: 8000, - }) - - if err := store.Initialize(); err != nil { - t.Fatalf("initialize store: %v", err) - } - - if _, err := store.DB().Exec( - `INSERT INTO music (id,type,name,asset,heat,createTimestamp) VALUES - ('HEAT02',1,'two','two.mp3',2,0), - ('HEAT01',1,'one','one.mp3',1,0), - ('HEAT00',1,'zero','zero.mp3',0,0)`, - ); err != nil { - t.Fatalf("insert music: %v", err) - } - - result, err := decreaseMusicHeat() - if err != nil { - t.Fatalf("decrease music heat: %v", err) - } - if result.Metrics["updated_music_heat_rows"] != 2 { - t.Fatalf("updated rows = %d", result.Metrics["updated_music_heat_rows"]) - } - - expected := map[string]int64{ - "HEAT02": 1, - "HEAT01": 0, - "HEAT00": 0, - } - for id, want := range expected { - var got int64 - if err := store.DB().QueryRow(`SELECT heat FROM music WHERE id=?`, id).Scan(&got); err != nil { - t.Fatalf("query %s heat: %v", id, err) - } - if got != want { - t.Fatalf("%s heat = %d, want %d", id, got, want) - } - } -} - func writeMusicAssetForCleanTest(t *testing.T, filename string) { t.Helper() diff --git a/apps/cli/internal/store/lyric_test.go b/apps/cli/internal/store/lyric_test.go index c3ac9ba6e..23d5d63c5 100644 --- a/apps/cli/internal/store/lyric_test.go +++ b/apps/cli/internal/store/lyric_test.go @@ -134,3 +134,62 @@ func TestSearchMusicIDsByLyricRanksMatchesDeterministically(t *testing.T) { } } } + +func TestSearchMusicIDsByLyricOrdersSameRankByHeatThenCreateTimestamp(t *testing.T) { + if err := ResetForTests(); err != nil { + t.Fatalf("reset store: %v", err) + } + t.Cleanup(func() { + if err := ResetForTests(); err != nil { + t.Fatalf("cleanup store: %v", err) + } + }) + + config.Set(config.Config{ + Mode: config.ModeProduction, + Data: t.TempDir(), + Port: 8000, + }) + if err := Initialize(); err != nil { + t.Fatalf("initialize store: %v", err) + } + + now := time.Now().UnixMilli() + if _, err := DB().Exec( + `INSERT INTO music (id,type,name,asset,heat,createTimestamp) VALUES + ('SONG11', ?, 'Alpha', 'alpha.mp3', 10, ?), + ('SONG12', ?, 'Beta', 'beta.mp3', 10, ?), + ('SONG13', ?, 'Charlie', 'charlie.mp3', 5, ?), + ('SONG14', ?, 'Delta', 'delta.mp3', 5, ?)`, + int(MusicTypeSong), now-400, + int(MusicTypeSong), now-300, + int(MusicTypeSong), now-100, + int(MusicTypeSong), now-200, + ); err != nil { + t.Fatalf("insert music: %v", err) + } + if _, err := DB().Exec( + `INSERT INTO lyric (musicId,lrc,lrcContent) VALUES + ('SONG11','[00:00.00]hello alpha','hello alpha'), + ('SONG12','[00:00.00]hello beta','hello beta'), + ('SONG13','[00:00.00]hello charlie','hello charlie'), + ('SONG14','[00:00.00]hello delta','hello delta')`, + ); err != nil { + t.Fatalf("insert lyrics: %v", err) + } + + total, ids, err := SearchMusicIDsByLyric("hello", 1, 10) + if err != nil { + t.Fatalf("search lyrics: %v", err) + } + if total != 4 || len(ids) != 4 { + t.Fatalf("unexpected lyric search result: total=%d ids=%v", total, ids) + } + + want := []string{"SONG12", "SONG11", "SONG13", "SONG14"} + for i := range want { + if ids[i] != want[i] { + t.Fatalf("unexpected order: got %v want %v", ids, want) + } + } +} diff --git a/apps/cli/internal/store/migration/m_122_musicbill_cover_thumbnail.go b/apps/cli/internal/store/migration/m_122_musicbill_cover_thumbnail.go new file mode 100644 index 000000000..e7a42dcb8 --- /dev/null +++ b/apps/cli/internal/store/migration/m_122_musicbill_cover_thumbnail.go @@ -0,0 +1,80 @@ +package migration + +import ( + "context" + "database/sql" + "fmt" +) + +// Migration 121 -> 122: persist tiny data-URL thumbnails for musicbill covers +// so clients can show the same immediate placeholder behavior used by music. +func init() { + Register(Migration{ + From: BaselineVersion + 21, + To: BaselineVersion + 22, + Description: "add tiny image thumbnails to musicbill covers", + Up: upMusicbillCoverThumbnails, + }) +} + +func upMusicbillCoverThumbnails(ctx context.Context, env *Env) error { + if err := addMusicbillCoverThumbnailColumn(ctx, env.Tx); err != nil { + return err + } + return backfillMusicbillCoverThumbnails(ctx, env) +} + +func addMusicbillCoverThumbnailColumn(ctx context.Context, tx *sql.Tx) error { + exists, err := migrationColumnExists(ctx, tx, "musicbill", "coverThumbnail") + if err != nil { + return err + } + if exists { + return nil + } + if _, err := tx.ExecContext(ctx, `ALTER TABLE musicbill ADD COLUMN coverThumbnail TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("add musicbill.coverThumbnail: %w", err) + } + return nil +} + +func backfillMusicbillCoverThumbnails(ctx context.Context, env *Env) error { + rows, err := env.Tx.QueryContext(ctx, `SELECT id,cover FROM musicbill WHERE cover!='' AND coverThumbnail=''`) + if err != nil { + return fmt.Errorf("read musicbill covers: %w", err) + } + + type musicbillCoverRow struct { + ID string + Cover string + } + var covers []musicbillCoverRow + for rows.Next() { + var row musicbillCoverRow + if err := rows.Scan(&row.ID, &row.Cover); err != nil { + rows.Close() + return fmt.Errorf("scan musicbill cover: %w", err) + } + covers = append(covers, row) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close musicbill cover rows: %w", err) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate musicbill covers: %w", err) + } + + for _, row := range covers { + if err := ctx.Err(); err != nil { + return err + } + thumbnail := thumbnailFromMigratedAsset(env.DataDir, "musicbill_cover", row.Cover) + if thumbnail == "" { + continue + } + if _, err := env.Tx.ExecContext(ctx, `UPDATE musicbill SET coverThumbnail=? WHERE id=?`, thumbnail, row.ID); err != nil { + return fmt.Errorf("backfill musicbill cover thumbnail %s: %w", row.ID, err) + } + } + return nil +} diff --git a/apps/cli/internal/store/migration/m_122_musicbill_cover_thumbnail_test.go b/apps/cli/internal/store/migration/m_122_musicbill_cover_thumbnail_test.go new file mode 100644 index 000000000..433935c9f --- /dev/null +++ b/apps/cli/internal/store/migration/m_122_musicbill_cover_thumbnail_test.go @@ -0,0 +1,72 @@ +package migration + +import ( + "context" + "database/sql" + "path/filepath" + "strings" + "testing" + + _ "modernc.org/sqlite" +) + +func TestM122_MusicbillCoverThumbnails(t *testing.T) { + resetForTests() + dir := t.TempDir() + writeV(t, dir, BaselineVersion+21) + + cover := "abcover.jpg" + writeMigrationTestJPEG(t, filepath.Join(dir, "assets", "musicbill_cover", "ab", cover)) + + db, err := sql.Open("sqlite", filepath.Join(dir, "db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + db.SetMaxOpenConns(1) + if _, err := db.Exec(` + PRAGMA journal_mode=WAL; + CREATE TABLE musicbill ( + id TEXT PRIMARY KEY NOT NULL, + cover TEXT NOT NULL DEFAULT '' + ); + `); err != nil { + db.Close() + t.Fatalf("seed db: %v", err) + } + if _, err := db.Exec(`INSERT INTO musicbill (id, cover) VALUES ('musicbill-1', ?)`, cover); err != nil { + db.Close() + t.Fatalf("insert musicbill: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("close seed db: %v", err) + } + + Register(Migration{ + From: BaselineVersion + 21, + To: BaselineVersion + 22, + Description: "m122", + Up: upMusicbillCoverThumbnails, + }) + + if err := Run(context.Background(), dir); err != nil { + t.Fatalf("Run: %v", err) + } + + if !columnExists(t, dir, "musicbill", "coverThumbnail") { + t.Fatal("expected musicbill.coverThumbnail column") + } + + db, err = sql.Open("sqlite", filepath.Join(dir, "db")) + if err != nil { + t.Fatalf("open upgraded db: %v", err) + } + defer db.Close() + + var thumbnail string + if err := db.QueryRow(`SELECT coverThumbnail FROM musicbill WHERE id='musicbill-1'`).Scan(&thumbnail); err != nil { + t.Fatalf("read cover thumbnail: %v", err) + } + if !strings.HasPrefix(thumbnail, "data:image/jpeg;base64,") { + t.Fatalf("expected musicbill cover thumbnail data URL, got %q", thumbnail) + } +} diff --git a/apps/cli/internal/store/music_test.go b/apps/cli/internal/store/music_test.go index 5b3deee25..a0dcc9d4c 100644 --- a/apps/cli/internal/store/music_test.go +++ b/apps/cli/internal/store/music_test.go @@ -235,6 +235,57 @@ func TestSearchMusicRanksNameMatchesAndEscapesWildcards(t *testing.T) { } } +func TestSearchMusicOrdersSameRankByHeatThenCreateTimestamp(t *testing.T) { + if err := ResetForTests(); err != nil { + t.Fatalf("reset store: %v", err) + } + t.Cleanup(func() { + if err := ResetForTests(); err != nil { + t.Fatalf("cleanup store: %v", err) + } + }) + + config.Set(config.Config{ + Mode: config.ModeProduction, + Data: t.TempDir(), + Port: 8000, + }) + if err := Initialize(); err != nil { + t.Fatalf("initialize store: %v", err) + } + + now := time.Now().UnixMilli() + if _, err := DB().Exec( + `INSERT INTO music (id,type,name,aliases,asset,heat,createTimestamp) VALUES + ('MUS211', ?, 'Love Alpha', '', 'alpha.mp3', 10, ?), + ('MUS212', ?, 'Love Beta', '', 'beta.mp3', 10, ?), + ('MUS213', ?, 'Love Charlie', '', 'charlie.mp3', 5, ?), + ('MUS214', ?, 'Love Delta', '', 'delta.mp3', 5, ?)`, + int(MusicTypeSong), now-400, + int(MusicTypeSong), now-300, + int(MusicTypeSong), now-100, + int(MusicTypeSong), now-200, + ); err != nil { + t.Fatalf("insert music: %v", err) + } + + total, musics, err := SearchMusic("Love", 1, 10) + if err != nil { + t.Fatalf("search music: %v", err) + } + if total != 4 || len(musics) != 4 { + t.Fatalf("unexpected search result: total=%d musics=%+v", total, musics) + } + + got := []string{musics[0].ID, musics[1].ID, musics[2].ID, musics[3].ID} + want := []string{"MUS212", "MUS211", "MUS213", "MUS214"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("unexpected order: got %v want %v", got, want) + } + } +} + func TestGetMusicsByIDsPreservesInputOrder(t *testing.T) { if err := ResetForTests(); err != nil { t.Fatalf("reset store: %v", err) diff --git a/apps/cli/internal/store/musicbill.go b/apps/cli/internal/store/musicbill.go index 3b12389ee..620164f4e 100644 --- a/apps/cli/internal/store/musicbill.go +++ b/apps/cli/internal/store/musicbill.go @@ -14,6 +14,7 @@ type Musicbill struct { ID string UserID string Cover string + CoverThumbnail string Name string Public int CreateTimestamp int64 @@ -51,15 +52,15 @@ type MusicInMusicbill struct { func GetMusicbillByID(id string) (*MusicbillWithOwner, error) { mb := &MusicbillWithOwner{} err := DB().QueryRow( - `SELECT mb.id,mb.userId,mb.cover,mb.name,mb.public,mb.createTimestamp,u.nickname,u.avatar + `SELECT mb.id,mb.userId,mb.cover,mb.coverThumbnail,mb.name,mb.public,mb.createTimestamp,u.nickname,u.avatar FROM musicbill mb JOIN user u ON mb.userId=u.id WHERE mb.id=?`, id, - ).Scan(&mb.ID, &mb.UserID, &mb.Cover, &mb.Name, &mb.Public, &mb.CreateTimestamp, &mb.OwnerNickname, &mb.OwnerAvatar) + ).Scan(&mb.ID, &mb.UserID, &mb.Cover, &mb.CoverThumbnail, &mb.Name, &mb.Public, &mb.CreateTimestamp, &mb.OwnerNickname, &mb.OwnerAvatar) return mb, err } func GetMusicbillsByUserID(userID string) ([]Musicbill, error) { rows, err := DB().Query( - `SELECT id,userId,cover,name,public,createTimestamp FROM musicbill WHERE userId=? ORDER BY createTimestamp DESC`, userID, + `SELECT id,userId,cover,coverThumbnail,name,public,createTimestamp FROM musicbill WHERE userId=? ORDER BY createTimestamp DESC`, userID, ) if err != nil { return nil, err @@ -68,7 +69,7 @@ func GetMusicbillsByUserID(userID string) ([]Musicbill, error) { var out []Musicbill for rows.Next() { mb := Musicbill{} - rows.Scan(&mb.ID, &mb.UserID, &mb.Cover, &mb.Name, &mb.Public, &mb.CreateTimestamp) + rows.Scan(&mb.ID, &mb.UserID, &mb.Cover, &mb.CoverThumbnail, &mb.Name, &mb.Public, &mb.CreateTimestamp) out = append(out, mb) } return out, nil @@ -105,6 +106,11 @@ func UpdateMusicbill(id, field string, value any) error { return err } +func UpdateMusicbillCover(id, cover, coverThumbnail string) error { + _, err := DB().Exec(`UPDATE musicbill SET cover=?,coverThumbnail=? WHERE id=?`, cover, coverThumbnail, id) + return err +} + func DeleteMusicbill(id string) error { tx, err := DB().Begin() if err != nil { @@ -323,9 +329,9 @@ func TransferMusicbillOwner(musicbillID, fromUserID, toUserID string) (bool, err func GetPublicMusicbillByID(id string) (*MusicbillWithOwner, error) { mb := &MusicbillWithOwner{} err := DB().QueryRow( - `SELECT mb.id,mb.userId,mb.cover,mb.name,mb.public,mb.createTimestamp,u.nickname,u.avatar + `SELECT mb.id,mb.userId,mb.cover,mb.coverThumbnail,mb.name,mb.public,mb.createTimestamp,u.nickname,u.avatar FROM musicbill mb JOIN user u ON mb.userId=u.id WHERE mb.id=? AND mb.public=1`, id, - ).Scan(&mb.ID, &mb.UserID, &mb.Cover, &mb.Name, &mb.Public, &mb.CreateTimestamp, &mb.OwnerNickname, &mb.OwnerAvatar) + ).Scan(&mb.ID, &mb.UserID, &mb.Cover, &mb.CoverThumbnail, &mb.Name, &mb.Public, &mb.CreateTimestamp, &mb.OwnerNickname, &mb.OwnerAvatar) return mb, err } @@ -346,7 +352,7 @@ func SearchPublicMusicbills(keyword string, page, pageSize int) (int, []Musicbil ).Scan(&total) // 搜索页需要直接展示乐单音乐数量,在同一条查询里补齐避免二次请求。 rows, err := DB().Query( - `SELECT mb.id,mb.userId,mb.cover,mb.name,mb.public,mb.createTimestamp,u.nickname,u.avatar, + `SELECT mb.id,mb.userId,mb.cover,mb.coverThumbnail,mb.name,mb.public,mb.createTimestamp,u.nickname,u.avatar, (SELECT COUNT(1) FROM musicbill_music mm WHERE mm.musicbillId=mb.id) AS musicCount FROM musicbill mb JOIN user u ON mb.userId=u.id WHERE mb.public=1 @@ -371,7 +377,7 @@ func SearchPublicMusicbills(keyword string, page, pageSize int) (int, []Musicbil var out []MusicbillWithOwner for rows.Next() { mb := MusicbillWithOwner{} - rows.Scan(&mb.ID, &mb.UserID, &mb.Cover, &mb.Name, &mb.Public, &mb.CreateTimestamp, &mb.OwnerNickname, &mb.OwnerAvatar, &mb.MusicCount) + rows.Scan(&mb.ID, &mb.UserID, &mb.Cover, &mb.CoverThumbnail, &mb.Name, &mb.Public, &mb.CreateTimestamp, &mb.OwnerNickname, &mb.OwnerAvatar, &mb.MusicCount) out = append(out, mb) } return total, out, nil diff --git a/apps/cli/internal/store/schema.go b/apps/cli/internal/store/schema.go index 0e166ad7c..b2aaa877b 100644 --- a/apps/cli/internal/store/schema.go +++ b/apps/cli/internal/store/schema.go @@ -134,6 +134,7 @@ var tables = []string{ id TEXT PRIMARY KEY NOT NULL CHECK(length(id)=6 AND id GLOB '[0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z][0-9A-Z]'), userId TEXT NOT NULL REFERENCES user(id), cover TEXT NOT NULL DEFAULT '', + coverThumbnail TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, public INTEGER NOT NULL DEFAULT 0, createTimestamp INTEGER NOT NULL diff --git a/apps/cli/storybook/storybook_dev.go b/apps/cli/storybook/storybook_dev.go new file mode 100644 index 000000000..bb68ff9f9 --- /dev/null +++ b/apps/cli/storybook/storybook_dev.go @@ -0,0 +1,9 @@ +//go:build !prod + +package storybook + +import "github.com/gin-gonic/gin" + +// Register is a no-op in development mode. Run Storybook with its own dev +// server (`npm run storybook --prefix apps/pwa`) instead. +func Register(r *gin.Engine) {} diff --git a/apps/cli/storybook/storybook_prod.go b/apps/cli/storybook/storybook_prod.go new file mode 100644 index 000000000..04511c8bf --- /dev/null +++ b/apps/cli/storybook/storybook_prod.go @@ -0,0 +1,43 @@ +//go:build prod + +package storybook + +import ( + "embed" + "io/fs" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +//go:embed all:static +var storybookFS embed.FS + +// Register mounts the embedded Storybook static build under `/storybook`. +// Storybook 的静态产物使用相对路径引用资源,因此在子路径下托管时必须带尾斜杠, +// 否则 `index.html` 里的相对资源会解析到根路径。这里把 `/storybook` 重定向到 +// `/storybook/`,其余文件交给基于 embed FS 的 FileServer 处理。 +func Register(r *gin.Engine) { + sub, err := fs.Sub(storybookFS, "static") + if err != nil { + panic(err) + } + fileServer := http.StripPrefix("/storybook/", http.FileServer(http.FS(sub))) + + r.GET("/storybook", func(c *gin.Context) { + c.Redirect(http.StatusMovedPermanently, "/storybook/") + }) + r.GET("/storybook/*filepath", func(c *gin.Context) { + filepath := strings.TrimPrefix(c.Param("filepath"), "/") + // 根路径交给 FileServer 返回 index.html;其余仅当真实文件存在时才服务, + // 未声明的路径一律 404,避免误把任意路径当作 index.html 返回。 + if filepath != "" { + if info, err := fs.Stat(sub, filepath); err != nil || info.IsDir() { + c.AbortWithStatus(http.StatusNotFound) + return + } + } + fileServer.ServeHTTP(c.Writer, c.Request) + }) +} diff --git a/apps/cli/storybook/storybook_prod_test.go b/apps/cli/storybook/storybook_prod_test.go new file mode 100644 index 000000000..4908d263b --- /dev/null +++ b/apps/cli/storybook/storybook_prod_test.go @@ -0,0 +1,65 @@ +//go:build prod + +package storybook + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func newEngine() *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + Register(r) + return r +} + +func TestRedirectToTrailingSlash(t *testing.T) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/storybook", nil) + newEngine().ServeHTTP(w, req) + + if w.Code != http.StatusMovedPermanently { + t.Fatalf("want 301, got %d", w.Code) + } + if loc := w.Header().Get("Location"); loc != "/storybook/" { + t.Fatalf("want redirect to /storybook/, got %q", loc) + } +} + +func TestServesIndex(t *testing.T) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/storybook/", nil) + newEngine().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("want 200 for index, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), "=0.10.0" } @@ -8036,7 +8025,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8819,7 +8807,6 @@ "integrity": "sha512-p8seiSI6FiVY6P3V0pG+5v7c8pDMehMAFRWEhG5XqIBSQszzOjDnW2rNvm3odoLKfo3V3P6Cs6Hv9ILzymULyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@storybook/core": "8.6.18" }, @@ -9212,7 +9199,6 @@ "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -9446,12 +9432,11 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9717,7 +9702,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/apps/pwa/package.json b/apps/pwa/package.json index d0c51923a..220187e02 100644 --- a/apps/pwa/package.json +++ b/apps/pwa/package.json @@ -59,7 +59,7 @@ "@vitejs/plugin-react": "^4.0.0", "sass": "^1.65.1", "storybook": "^8.6.18", - "typescript": "^5.9.3", + "typescript": "^6.0.3", "vite": "^6.0.0", "vite-plugin-pwa": "^1.3.0", "workbox-cacheable-response": "^7.4.1", diff --git a/apps/pwa/src/asset.d.ts b/apps/pwa/src/asset.d.ts index 97f66610c..4ad8f6692 100644 --- a/apps/pwa/src/asset.d.ts +++ b/apps/pwa/src/asset.d.ts @@ -12,3 +12,5 @@ declare module '*.png' { const value: string; export default value; } + +declare module '*.css'; diff --git a/apps/pwa/src/asset/default_cover.jpeg b/apps/pwa/src/asset/default_cover.jpeg deleted file mode 100644 index 57561f4cd..000000000 Binary files a/apps/pwa/src/asset/default_cover.jpeg and /dev/null differ diff --git a/apps/pwa/src/components/app_drawer/app_drawer.stories.tsx b/apps/pwa/src/components/app_drawer/app_drawer.stories.tsx index 97d14252f..9f79b42ed 100644 --- a/apps/pwa/src/components/app_drawer/app_drawer.stories.tsx +++ b/apps/pwa/src/components/app_drawer/app_drawer.stories.tsx @@ -15,11 +15,9 @@ const meta = { control: 'select', options: ['compact', 'medium', 'wide'], }, - showClose: { control: 'boolean' }, }, args: { width: 'medium', - showClose: true, open: false, onClose: () => {}, children: null, diff --git a/apps/pwa/src/components/avatar/avatar.stories.tsx b/apps/pwa/src/components/avatar/avatar.stories.tsx index 5896bf985..eedd0efd3 100644 --- a/apps/pwa/src/components/avatar/avatar.stories.tsx +++ b/apps/pwa/src/components/avatar/avatar.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; -import DefaultCover from '@/asset/default_cover.jpeg'; +import DefaultCover from '@/static/apple-touch-icon_v1.png'; import Avatar from '.'; const meta = { diff --git a/apps/pwa/src/components/avatar/index.tsx b/apps/pwa/src/components/avatar/index.tsx index 5eb2a9fe5..e2acb9df3 100644 --- a/apps/pwa/src/components/avatar/index.tsx +++ b/apps/pwa/src/components/avatar/index.tsx @@ -1,11 +1,15 @@ import { HTMLAttributes } from 'react'; import { CSSVariable } from '@/global_style'; -import ImageFrame, { getImageFrameRadius } from '@/components/image_frame'; +import ImageFrame from '@/components/image_frame'; +import { CONTROL_SIZE } from '@/components/control_style'; import { CSS_VAR } from '../theme'; const PRIMARY = `var(${CSS_VAR.colorPrimary})`; const PRIMARY_SHADOW = `var(${CSS_VAR.colorPrimaryShadow})`; const NEUTRAL_SHADOW = CSSVariable.COLOR_NEUTRAL_SHADOW; +const DEFAULT_RADIUS = CONTROL_SIZE.lg.radius; +const SM_RADIUS_MAX = CONTROL_SIZE.sm.height; +const MD_RADIUS_MAX = CONTROL_SIZE.md.height; function getBorderWidth() { return 2; @@ -25,6 +29,22 @@ function getShadowOffset(size: number | string) { return 4; } +function getAvatarRadius(size: number | string) { + if (typeof size !== 'number') { + return DEFAULT_RADIUS; + } + + if (size <= SM_RADIUS_MAX) { + return CONTROL_SIZE.sm.radius; + } + + if (size <= MD_RADIUS_MAX) { + return CONTROL_SIZE.md.radius; + } + + return CONTROL_SIZE.lg.radius; +} + export interface AvatarProps extends HTMLAttributes { src: string; size?: number | string; @@ -39,7 +59,7 @@ function Avatar({ ...props }: AvatarProps) { const borderWidth = getBorderWidth(); - const radius = getImageFrameRadius(size); + const radius = getAvatarRadius(size); return ( `var(${v})`; -const PRIMARY = cn(CSS_VAR.colorPrimary); +const PRIMARY = cn(CSS_VAR.colorPrimary); const PRIMARY_SHADOW = cn(CSS_VAR.colorPrimaryShadow); const CONTROL_NEUTRAL = CSSVariable.COLOR_CONTROL_NEUTRAL; const NEUTRAL_SHADOW = CSSVariable.COLOR_NEUTRAL_SHADOW; @@ -54,15 +55,6 @@ const SIZE_MAP: Record> = { `, }; -// ─── 变体 ───────────────────────────────────────────────────────────────────── -// -// Duolingo 核心公式: -// 正常 — 纯色填充 + 底部纯色硬阴影(无 blur) -// 悬停 — 向上抬起 2px + 阴影加深(同步播放器发现页卡片) -// 按下 — translateY(offset) + box-shadow 归零 -// 释放 — 慢速弹回(150ms ease-out) -// 禁用 — 保留更浅的硬阴影,避免视觉高度变矮 - const makeVariant = ( face: string, shadow: string, @@ -77,18 +69,20 @@ const makeVariant = ( box-shadow: 0 ${({ $offset }) => $offset}px 0 ${shadow}; - &:not(:disabled):hover { - ${({ $disableHoverLift, $offset }) => - $disableHoverLift - ? css` - transform: none; - box-shadow: 0 ${$offset}px 0 ${shadow}; - ` - : css` - transform: translateY(-2px); - box-shadow: 0 ${$offset + 2}px 0 ${shadow}; - `} - } + ${hover(css<{ $disableHoverLift: boolean; $offset: number }>` + &:not(:disabled):hover { + ${({ $disableHoverLift, $offset }) => + $disableHoverLift + ? css` + transform: none; + box-shadow: 0 ${$offset}px 0 ${shadow}; + ` + : css` + transform: translateY(-2px); + box-shadow: 0 ${$offset + 2}px 0 ${shadow}; + `} + } + `)} &:not(:disabled):active { transform: translateY(${({ $offset }) => $offset}px); @@ -100,10 +94,10 @@ const makeVariant = ( `; const VARIANT_MAP: Record> = { - primary: makeVariant(PRIMARY, PRIMARY_SHADOW), - secondary: makeVariant('#ffffff', PRIMARY, PRIMARY), - ghost: makeVariant('#ffffff', NEUTRAL_SHADOW, 'rgb(88 88 88)'), - danger: makeVariant('rgb(242 80 66)', 'rgb(190 46 34)'), + primary: makeVariant(PRIMARY, PRIMARY_SHADOW), + secondary: makeVariant('#ffffff', PRIMARY, PRIMARY), + ghost: makeVariant('#ffffff', NEUTRAL_SHADOW, 'rgb(88 88 88)'), + danger: makeVariant('rgb(242 80 66)', 'rgb(190 46 34)'), }; const FOCUS_RING_MAP: Record = { diff --git a/apps/pwa/src/components/cover/cover.stories.tsx b/apps/pwa/src/components/cover/cover.stories.tsx index 78421c2b2..f005dd32a 100644 --- a/apps/pwa/src/components/cover/cover.stories.tsx +++ b/apps/pwa/src/components/cover/cover.stories.tsx @@ -2,7 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react'; import { useState } from 'react'; import Cover from '.'; import { Shape } from './constants'; -import DefaultCover from '@/asset/default_cover.jpeg'; + +const SAMPLE_COVER = 'https://picsum.photos/seed/cicada-cover/256'; const meta = { title: 'Basic/Cover', @@ -13,7 +14,7 @@ const meta = { docs: { description: { component: - 'Square cover image with three shape variants. Loading is lazy via IntersectionObserver — the default cover shows until the real image is decoded, then the new image crossfades in with a subtle scale and saturation pop.', + 'Square cover image with three shape variants. Loading is lazy via IntersectionObserver, then the new image crossfades in with a subtle scale and saturation pop.', }, }, }, @@ -30,7 +31,7 @@ const meta = { }, }, args: { - src: DefaultCover, + src: SAMPLE_COVER, size: 160, shape: Shape.ROUNDED, }, @@ -56,7 +57,7 @@ export const AllShapes: Story = { gap: 8, }} > - + {shape} ))} @@ -70,7 +71,7 @@ export const AllSizes: Story = { render: () => (
{[48, 72, 96, 128, 160].map((size) => ( - + ))}
), @@ -81,7 +82,7 @@ export const SwapSource: Story = { parameters: { controls: { disable: true } }, render: () => { const sources = [ - DefaultCover, + SAMPLE_COVER, 'https://picsum.photos/seed/cicada-1/256', 'https://picsum.photos/seed/cicada-2/256', ]; diff --git a/apps/pwa/src/components/cover/cover.tsx b/apps/pwa/src/components/cover/cover.tsx index 7d775de58..597a3b03b 100644 --- a/apps/pwa/src/components/cover/cover.tsx +++ b/apps/pwa/src/components/cover/cover.tsx @@ -2,7 +2,7 @@ import { ImgHTMLAttributes, useLayoutEffect, useRef, useState } from 'react'; import { animated, useTransition } from '@react-spring/web'; import styled, { css } from 'styled-components'; import { ComponentSize } from '@/constants/style'; -import DefaultCover from '@/asset/default_cover.jpeg'; +import DefaultCover from '@/static/apple-touch-icon_v1.png'; import loadImage, { isImageLoaded } from '@/utils/load_image'; import logger from '@/utils/logger'; import { CSSVariable } from '@/global_style'; diff --git a/apps/pwa/src/components/dialog/index.tsx b/apps/pwa/src/components/dialog/index.tsx index 20d52b2f0..7b3fcbea6 100644 --- a/apps/pwa/src/components/dialog/index.tsx +++ b/apps/pwa/src/components/dialog/index.tsx @@ -34,8 +34,9 @@ import { useState, } from 'react'; import * as RadixDialog from '@radix-ui/react-dialog'; -import styled, { keyframes } from 'styled-components'; +import styled, { css, keyframes } from 'styled-components'; import { CSSVariable } from '@/global_style'; +import hover from '@/style/hover'; import { useTheme, CSS_VAR } from '../theme'; import { t } from '@/i18n'; import { @@ -241,7 +242,9 @@ const CloseButton = styled(RadixDialog.Close)` cursor: pointer; transition: color 120ms, background 120ms; - &:hover { color: rgb(60 60 60); background: rgb(240 240 240); } + ${hover(css` + &:hover { color: rgb(60 60 60); background: rgb(240 240 240); } + `)} &:focus-visible { outline: 3px solid currentColor; outline-offset: 2px; diff --git a/apps/pwa/src/components/divider/divider.stories.tsx b/apps/pwa/src/components/divider/divider.stories.tsx index 176ebfc06..d8f124a91 100644 --- a/apps/pwa/src/components/divider/divider.stories.tsx +++ b/apps/pwa/src/components/divider/divider.stories.tsx @@ -10,7 +10,7 @@ const meta = { docs: { description: { component: - 'Duolingo-style divider. Without a label renders a plain 2px horizontal rule. With a label renders an "OR"-style separator with the text centred between two lines.', + 'Divider. Without a label renders a plain 2px horizontal rule. With a label renders an "OR"-style separator with the text centred between two lines.', }, }, }, diff --git a/apps/pwa/src/components/drawer/drawer.stories.tsx b/apps/pwa/src/components/drawer/drawer.stories.tsx index b1d0672d9..442f684c3 100644 --- a/apps/pwa/src/components/drawer/drawer.stories.tsx +++ b/apps/pwa/src/components/drawer/drawer.stories.tsx @@ -33,11 +33,6 @@ const meta = { description: 'Which edge the drawer slides from.', table: { defaultValue: { summary: 'right' } }, }, - showClose: { - control: 'boolean', - description: 'Show the close button.', - table: { defaultValue: { summary: 'false' } }, - }, }, } satisfies Meta; @@ -50,13 +45,11 @@ function DrawerDemo({ side = 'right', title = 'Drawer', description, - showClose = false, longContent = false, }: { side?: 'left' | 'right' | 'bottom'; title?: string; description?: string; - showClose?: boolean; longContent?: boolean; }) { const [open, setOpen] = useState(false); @@ -64,7 +57,7 @@ function DrawerDemo({ <> - + {title} {description && {description}} @@ -162,30 +155,6 @@ export const LongContent: Story = { ), }; -export const NoCloseButton: Story = { - name: 'No close button', - render: () => ( - - ), -}; - -export const WithCloseButton: Story = { - name: 'With close button', - render: () => ( - - ), -}; - export const AllSides: Story = { name: 'All sides', render: () => ( diff --git a/apps/pwa/src/components/drawer/index.tsx b/apps/pwa/src/components/drawer/index.tsx index fcb2b658a..a09b933a3 100644 --- a/apps/pwa/src/components/drawer/index.tsx +++ b/apps/pwa/src/components/drawer/index.tsx @@ -45,7 +45,6 @@ import { useRegisterDialogTitle, visuallyHiddenStyle, } from '../dialog_a11y'; -import { Close } from '@/components/icon'; // ─── Tokens ─────────────────────────────────────────────────────────────────── @@ -152,53 +151,8 @@ const Panel = styled.div<{ $side: DrawerSide }>` ${({ $side }) => SIDE_MAP[$side]} `; -// ─── Close button ───────────────────────────────────────────────────────────── - -const CloseButton = styled(RadixDialog.Close)` - position: absolute; - top: 12px; - right: 12px; - z-index: 1; - display: flex; - align-items: center; - justify-content: center; - width: 34px; - height: 34px; - padding: 0; - border: 2px solid ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - border-radius: 10px; - background: #fff; - box-shadow: 0 3px 0 ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - color: rgb(88 88 88); - cursor: pointer; - flex-shrink: 0; - font-size: 20px; - transition: - transform 150ms ease-out, - box-shadow 150ms ease-out, - filter 120ms; - - &:hover { - filter: brightness(1.06); - } - - &:active { - transform: translateY(3px); - box-shadow: none; - transition: - transform 60ms ease-in, - box-shadow 60ms ease-in, - filter 60ms; - } - - &:focus-visible { - outline: 3px solid ${CSSVariable.COLOR_CONTROL_NEUTRAL}; - outline-offset: 2px; - } -`; - // ─── Scroll area ────────────────────────────────────────────────────────────── -// Wraps all slotted children so they can scroll while the close button stays fixed. +// Wraps all slotted children so they can scroll within the panel. const ScrollArea = styled.div` flex: 1; @@ -217,8 +171,6 @@ export interface DrawerContentProps extends ComponentPropsWithoutRef { /** Which edge the drawer slides from. Default: 'right'. */ side?: DrawerSide; - /** Show the close button. Default: false. */ - showClose?: boolean; /** Screen reader title used when no DrawerTitle is rendered. */ accessibleTitle?: ReactNode; /** @@ -237,7 +189,6 @@ export const DrawerContent = forwardRef< >(({ children, side = 'right', - showClose = false, zIndex = DEFAULT_Z_INDEX, style, accessibleTitle, @@ -275,11 +226,6 @@ export const DrawerContent = forwardRef< {fallbackTitle} - {showClose && ( - - - - )} {children} diff --git a/apps/pwa/src/components/image_frame/index.tsx b/apps/pwa/src/components/image_frame/index.tsx index d65c94153..8e0afce3d 100644 --- a/apps/pwa/src/components/image_frame/index.tsx +++ b/apps/pwa/src/components/image_frame/index.tsx @@ -1,5 +1,6 @@ import { HTMLAttributes } from 'react'; import styled, { css } from 'styled-components'; +import hover from '@/style/hover'; import Cover, { Shape } from '@/components/cover'; const FACE = '#ffffff'; @@ -62,10 +63,12 @@ const Root = styled.div<{ user-select: none; -webkit-tap-highlight-color: transparent; - &:hover { - transform: translateY(-2px); - box-shadow: 0 ${$shadowOffset + 2}px 0 ${$shadowColor}; - } + ${hover(css` + &:hover { + transform: translateY(-2px); + box-shadow: 0 ${$shadowOffset + 2}px 0 ${$shadowColor}; + } + `)} &:active { transform: translateY(${$shadowOffset}px); diff --git a/apps/pwa/src/components/image_viewer/image_viewer.stories.tsx b/apps/pwa/src/components/image_viewer/image_viewer.stories.tsx index 9266d6863..d85797d3a 100644 --- a/apps/pwa/src/components/image_viewer/image_viewer.stories.tsx +++ b/apps/pwa/src/components/image_viewer/image_viewer.stories.tsx @@ -2,7 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react'; import { useState } from 'react'; import ImageViewer, { type ImageViewerPhoto } from '.'; import Button from '../button'; -import DefaultCover from '@/asset/default_cover.jpeg'; + +const SAMPLE_IMAGE = 'https://picsum.photos/seed/cicada-viewer/1600/1000'; function ImageViewerDemo({ src, alt }: { src: string; alt: string }) { const [photo, setPhoto] = useState(null); @@ -28,8 +29,8 @@ const meta = { }, }, args: { - src: DefaultCover, - alt: 'Default cover', + src: SAMPLE_IMAGE, + alt: 'Sample image', }, } satisfies Meta; @@ -41,7 +42,7 @@ export const Basic: Story = {}; export const RemoteImage: Story = { name: 'Remote image', args: { - src: 'https://picsum.photos/seed/cicada-viewer/1600/1000', + src: SAMPLE_IMAGE, alt: 'Random landscape', }, }; diff --git a/apps/pwa/src/components/input/input.stories.tsx b/apps/pwa/src/components/input/input.stories.tsx index e6a371a33..d74bb2a85 100644 --- a/apps/pwa/src/components/input/input.stories.tsx +++ b/apps/pwa/src/components/input/input.stories.tsx @@ -10,7 +10,7 @@ const meta = { docs: { description: { component: - 'Text input field with Duolingo-style hard shadow. Supports label and error messages. Built with `forwardRef` for compatibility with form libraries like React Hook Form.', + 'Text input field with hard shadow. Supports label and error messages. Built with `forwardRef` for compatibility with form libraries like React Hook Form.', }, }, }, @@ -28,10 +28,10 @@ const meta = { description: 'Input size — aligns with Button sizes', table: { defaultValue: { summary: 'md' } }, }, - label: { control: 'text', description: 'Label rendered above the input' }, + label: { control: 'text', description: 'Label rendered above the input' }, placeholder: { control: 'text', description: 'Placeholder text' }, - error: { control: 'text', description: 'Error message — also triggers the error visual state' }, - disabled: { control: 'boolean', description: 'Disabled state' }, + error: { control: 'text', description: 'Error message — also triggers the error visual state' }, + disabled: { control: 'boolean', description: 'Disabled state' }, }, } satisfies Meta; @@ -50,9 +50,9 @@ export const States: Story = { parameters: { controls: { disable: true } }, render: () => (
- - - + + +
), }; @@ -62,9 +62,9 @@ export const Sizes: Story = { parameters: { controls: { disable: true } }, render: () => (
- + - +
), }; diff --git a/apps/pwa/src/components/pagination/index.tsx b/apps/pwa/src/components/pagination/index.tsx index 8f139baec..5c626e1cf 100644 --- a/apps/pwa/src/components/pagination/index.tsx +++ b/apps/pwa/src/components/pagination/index.tsx @@ -1,13 +1,11 @@ import { HtmlHTMLAttributes } from 'react'; import styled, { css } from 'styled-components'; import Button, { Size } from '@/components/button'; -import { CSSVariable } from '@/global_style'; import usePagination from './use_pagination'; import { t } from '@/i18n'; import { ChevronLeft, ChevronRight, MoreHorizontal } from '@/components/icon'; const GAP_MAP: Record = { sm: 5, md: 6, lg: 8 }; -const SHADOW_OFFSET: Record = { sm: 3, md: 4, lg: 5 }; const PAGINATION_SIZE: Size = 'sm'; const ELLIPSIS_SIZE: Record = { sm: { box: 34, icon: 18 }, @@ -20,38 +18,13 @@ const Style = styled.div<{ $gap: number }>` align-items: center; gap: ${({ $gap }) => $gap}px; `; -const PageButton = styled(Button)<{ $selected: boolean; $size: Size }>` +const PageButton = styled(Button)<{ $size: Size }>` min-width: ${({ $size }) => ELLIPSIS_SIZE[$size].box}px; line-height: 1; > .btn-label { line-height: 1; } - - ${({ $selected, $size }) => - !$selected && - css` - transition: - background 150ms ease-out, - border-color 150ms ease-out, - box-shadow 150ms ease-out, - transform 150ms ease-out, - filter 120ms ease-out; - - &:not(:disabled):hover { - background: #fff; - border-color: ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - box-shadow: 0 ${SHADOW_OFFSET[$size] + 2}px 0 - ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - } - - &:not(:disabled):active { - transition: - transform 60ms ease-in, - box-shadow 60ms ease-in, - filter 60ms ease-in; - } - `} `; const Ellipsis = styled.span<{ $size: Size }>` display: inline-flex; @@ -146,7 +119,6 @@ function Pagination({ ( ): StylesConfig, IsMulti, GroupBase>> { const s = SIZE[size]; const shadowColor = `color-mix(in srgb, ${primary} 70%, #000)`; - const optionShadow = Math.max(2, s.shadow - 1); return { control: (_, state) => ({ @@ -269,14 +268,13 @@ function buildStyles( ? `0 -4px 0 ${NEUTRAL_SHADOW}, 0 14px 28px rgb(0 0 0 / 0.1)` : `0 4px 0 ${NEUTRAL_SHADOW}, 0 14px 28px rgb(0 0 0 / 0.1)`, overflow: 'visible', - padding: 6, + padding: 4, marginTop: state.placement === 'top' ? 0 : s.shadow + 6, marginBottom: state.placement === 'top' ? s.shadow + 6 : 0, }), menuPortal: (base) => ({ ...base, zIndex: 10000, pointerEvents: 'auto' }), menuList: (_) => ({ - // Keep room inside the scroll clipping area for the last option's hard shadow. - padding: `0 0 ${optionShadow}px`, + padding: 0, maxHeight: 248, overflowY: 'auto' as const, scrollbarWidth: 'thin' as const, @@ -286,16 +284,11 @@ function buildStyles( alignItems: 'center', minHeight: Math.max(30, Math.round(s.height * 0.88)), padding: `0 ${s.px}px`, - marginTop: state.isSelected || state.isFocused ? 0 : 0, border: `2px solid ${ state.isSelected ? shadowColor : state.isFocused ? NEUTRAL_SHADOW : 'transparent' }`, borderRadius: Math.max(10, s.radius), - boxShadow: state.isSelected - ? `0 ${optionShadow}px 0 ${shadowColor}` - : state.isFocused - ? `0 ${optionShadow}px 0 ${NEUTRAL_SHADOW}` - : 'none', + boxShadow: 'none', fontFamily: FONT, fontSize: s.font, fontWeight: 800, @@ -304,15 +297,9 @@ function buildStyles( background: state.isSelected ? primary : '#fff', color: state.isSelected ? '#fff' : 'rgb(55 55 55)', transition: - 'background 120ms, border-color 120ms, box-shadow 120ms, color 120ms', - ':active': { - transform: state.isSelected || state.isFocused - ? `translateY(${optionShadow}px)` - : undefined, - boxShadow: 'none', - }, + 'background 120ms, border-color 120ms, color 120ms', ':not(:first-of-type)': { - marginTop: 6, + marginTop: 4, }, }), multiValue: (_) => ({ @@ -324,7 +311,6 @@ function buildStyles( border: `2px solid ${isDisabled ? DISABLED_SHADOW : NEUTRAL_SHADOW}`, borderRadius: 8, background: isDisabled ? DISABLED_BACKGROUND : '#fff', - boxShadow: `0 2px 0 ${isDisabled ? DISABLED_SHADOW : NEUTRAL_SHADOW}`, flexShrink: 0, maxWidth: wrapValues ? 'min(180px, 100%)' : size === 'sm' ? 84 : 140, }), @@ -424,7 +410,12 @@ export function Select({ getOptionValue={(o) => toKey(o.value)} menuPlacement={menuPlacement} menuPortalTarget={document.body} - menuPosition="fixed" + // Body portal escapes drawer/overflow clipping; `absolute` (react-select's + // default) keeps the menu anchored in document coordinates. `fixed` anchors + // to the layout viewport, which iOS Safari mis-resolves inside a fixed drawer + // (and when the soft keyboard shrinks the visual viewport), placing the menu + // in the wrong spot. See MultiSelect below for the same reasoning. + menuPosition="absolute" components={{ DropdownIndicator, Menu, MenuList, MenuPortal }} /> @@ -581,7 +572,10 @@ export function MultiSelect({ styles, getOptionValue: (o: SelectOption) => toKey(o.value), menuPortalTarget: document.body, - menuPosition: 'fixed' as const, + // `absolute` (document-relative) instead of `fixed`; iOS Safari mis-positions a + // fixed-anchored menu inside the fixed drawer, worsened by the search keyboard + // shrinking the visual viewport. Body portal still avoids overflow clipping. + menuPosition: 'absolute' as const, components: selectComponents, closeMenuOnSelect: false, blurInputOnSelect: false, diff --git a/apps/pwa/src/components/select/select.stories.tsx b/apps/pwa/src/components/select/select.stories.tsx index 2394eca4f..5484b80a5 100644 --- a/apps/pwa/src/components/select/select.stories.tsx +++ b/apps/pwa/src/components/select/select.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { useArgs } from '@storybook/preview-api'; import { useState } from 'react'; import { Select, MultiSelect } from '.'; import type { SelectOption } from '.'; @@ -99,6 +100,16 @@ export const Playground: Story = { value: 'cherry', label: 'Fruit', }, + render: (args) => { + const [{ value }, updateArgs] = useArgs(); + return ( + event.preventDefault()} > {loading ? ( diff --git a/apps/pwa/src/pages/admin/music_management/music_list.tsx b/apps/pwa/src/pages/admin/music_management/music_list.tsx index 19de2cc1a..8c7bf0cca 100644 --- a/apps/pwa/src/pages/admin/music_management/music_list.tsx +++ b/apps/pwa/src/pages/admin/music_management/music_list.tsx @@ -316,9 +316,6 @@ const Td = styled.td` font-weight: 700; letter-spacing: 0; vertical-align: middle; - transition: - transform 150ms ease-out, - box-shadow 150ms ease-out; &:first-child { border-left: 2px solid ${CSSVariable.COLOR_BORDER}; @@ -335,18 +332,6 @@ const Td = styled.td` -6px 0 0 #fff, 0 3px 0 ${ROW_SHADOW}; } - - tbody tr:hover & { - z-index: 2; - transform: translateY(-2px); - box-shadow: 0 5px 0 ${ROW_SHADOW}; - } - - tbody tr:hover &:last-child { - box-shadow: - -6px 0 0 #fff, - 0 5px 0 ${ROW_SHADOW}; - } `; const Mono = styled.span` @@ -1145,9 +1130,7 @@ function MusicList({ ))} - ) : ( - {t('unknown')} - )} + ) : null} {music.type === MusicType.SONG && music.lyricists.length ? ( diff --git a/apps/pwa/src/pages/admin/user_management/index.tsx b/apps/pwa/src/pages/admin/user_management/index.tsx index 4a0711e99..ef32aeaed 100644 --- a/apps/pwa/src/pages/admin/user_management/index.tsx +++ b/apps/pwa/src/pages/admin/user_management/index.tsx @@ -257,9 +257,6 @@ const Td = styled.td` font-weight: 700; letter-spacing: 0; vertical-align: middle; - transition: - transform 150ms ease-out, - box-shadow 150ms ease-out; &:first-child { border-left: 2px solid ${CSSVariable.COLOR_BORDER}; @@ -276,18 +273,6 @@ const Td = styled.td` -6px 0 0 #fff, 0 3px 0 ${ROW_SHADOW}; } - - tbody tr:hover & { - z-index: 2; - transform: translateY(-2px); - box-shadow: 0 5px 0 ${ROW_SHADOW}; - } - - tbody tr:hover &:last-child { - box-shadow: - -6px 0 0 #fff, - 0 5px 0 ${ROW_SHADOW}; - } `; const UserName = styled.div` diff --git a/apps/pwa/src/pages/login/index.tsx b/apps/pwa/src/pages/login/index.tsx index 86dc7abd0..02e608643 100644 --- a/apps/pwa/src/pages/login/index.tsx +++ b/apps/pwa/src/pages/login/index.tsx @@ -48,9 +48,22 @@ const VersionFooter = styled.div` flex-shrink: 0; width: 100%; margin-top: 16px; + display: grid; + grid-template-columns: auto auto; + column-gap: 8px; + row-gap: 2px; + justify-content: center; + align-items: baseline; font-size: ${CSSVariable.TEXT_SIZE_SMALL}; color: ${CSSVariable.TEXT_COLOR_SECONDARY}; - text-align: center; +`; +const VersionLabel = styled.div` + text-align: right; +`; +const VersionValue = styled.div` + text-align: left; + font-variant-numeric: tabular-nums; + color: ${CSSVariable.TEXT_COLOR_PRIMARY}; `; const CENTER_TRANSFORM = 'translateX(0)'; @@ -157,20 +170,19 @@ function Login() { - {/* 第二步时已选定服务端, 将版本号拆成 server 和 pwa 各一行展示 */} + {/* label 右对齐 / value 左对齐的两列 grid, 冒号靠列对齐替代, 数字用 tabular-nums 等宽 */} {step === Step.SECOND && selectedServer ? ( <> -
- {t('server_version')}: {selectedServer.version} -
-
- {t('pwa_version')}: {definition.VERSION} -
+ {t('server_version')} + {selectedServer.version} + {t('pwa_version')} + {definition.VERSION} ) : ( -
- {t('pwa_version')}: {definition.VERSION} -
+ <> + {t('pwa_version')} + {definition.VERSION} + )}
diff --git a/apps/pwa/src/pages/login/manage_page.tsx b/apps/pwa/src/pages/login/manage_page.tsx index 4e609dec9..3cd8b4e9b 100644 --- a/apps/pwa/src/pages/login/manage_page.tsx +++ b/apps/pwa/src/pages/login/manage_page.tsx @@ -16,7 +16,6 @@ const Wrapper = styled.div` position: absolute; inset: 0; z-index: 10; - background: rgb(248 248 248); display: flex; flex-direction: column; animation: ${slideIn} 300ms cubic-bezier(0.16, 1, 0.3, 1); diff --git a/apps/pwa/src/pages/player/artist_drawer/artist_drawer.tsx b/apps/pwa/src/pages/player/artist_drawer/artist_drawer.tsx deleted file mode 100644 index 80d9b1a8f..000000000 --- a/apps/pwa/src/pages/player/artist_drawer/artist_drawer.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import AppDrawer from '@/components/app_drawer'; -import ArtistContent from './content'; - -function ArtistDrawer({ - open, - onClose, - id, - zIndex, -}: { - open: boolean; - onClose: () => void; - id: string; - zIndex: number; -}) { - return ( - - - - ); -} - -export default ArtistDrawer; diff --git a/apps/pwa/src/pages/player/artist_drawer/constants.ts b/apps/pwa/src/pages/player/artist_drawer/constants.ts deleted file mode 100644 index def31a03f..000000000 --- a/apps/pwa/src/pages/player/artist_drawer/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -import getArtist from '@/server/api/get_artist'; - -export type Artist = AsyncReturnType; diff --git a/apps/pwa/src/pages/player/artist_drawer/content.tsx b/apps/pwa/src/pages/player/artist_drawer/content.tsx deleted file mode 100644 index 20362ce1f..000000000 --- a/apps/pwa/src/pages/player/artist_drawer/content.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/features/player/drawers/artist_drawer/content'; diff --git a/apps/pwa/src/pages/player/artist_drawer/index.tsx b/apps/pwa/src/pages/player/artist_drawer/index.tsx deleted file mode 100644 index 764c387da..000000000 --- a/apps/pwa/src/pages/player/artist_drawer/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/features/player/drawers/artist_drawer'; diff --git a/apps/pwa/src/pages/player/artist_drawer/info.tsx b/apps/pwa/src/pages/player/artist_drawer/info.tsx deleted file mode 100644 index 17f779851..000000000 --- a/apps/pwa/src/pages/player/artist_drawer/info.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { CSSVariable } from '@/global_style'; -import styled, { css } from 'styled-components'; -import { useEffect, useState, type Ref } from 'react'; -import Cover, { Shape } from '@/components/cover'; -import ImageViewer, { type ImageViewerPhoto } from '@/components/image_viewer'; -import { t } from '@/i18n'; -import { Artist } from './constants'; -import { PAGE_HORIZONTAL_PADDING } from '../pages/page'; - -const Style = styled.div` - background: #fff; - font-size: 0; -`; -const Identity = styled.section<{ - $integrated: boolean; -}>` - padding: ${({ $integrated }) => - $integrated ? 0 : `24px ${PAGE_HORIZONTAL_PADDING} 12px`}; - background: ${({ $integrated }) => ($integrated ? 'transparent' : '#fff')}; - - ${({ $integrated }) => - $integrated - ? css` - > .name { - -webkit-text-stroke: 0.35px rgb(255 255 255 / 0.9); - text-shadow: - 0 1px 0 rgb(255 255 255 / 0.95), - 0 0 10px rgb(255 255 255 / 0.9); - } - - > .aliases { - color: rgb(68 68 68); - text-shadow: - 0 1px 0 rgb(255 255 255 / 0.92), - 0 0 8px rgb(255 255 255 / 0.86); - } - ` - : null} - - > .name { - margin: 0; - - font-family: 'Nunito', 'Varela Round', system-ui, sans-serif; - font-size: 28px; - font-weight: 800; - line-height: 1.15; - letter-spacing: 0; - color: rgb(50 50 50); - overflow-wrap: anywhere; - } - - > .aliases { - margin: 8px 0 0; - - display: flex; - flex-direction: column; - gap: 3px; - - font-size: ${CSSVariable.TEXT_SIZE_NORMAL}; - font-weight: 600; - color: ${CSSVariable.TEXT_COLOR_SECONDARY}; - line-height: 1.3; - overflow-wrap: anywhere; - } -`; -const Main = styled.div` - position: relative; - overflow: hidden; - - > .photo { - display: block; - width: 100%; - border: none; - padding: 0; - background: transparent; - cursor: zoom-in; - -webkit-tap-highlight-color: transparent; - - &:focus-visible { - outline: 2px solid ${CSSVariable.COLOR_PRIMARY}; - outline-offset: -2px; - } - } -`; -const PhotoOverlay = styled.div<{ $hasThumbnails: boolean }>` - position: absolute; - left: 0; - right: 0; - bottom: 0; - z-index: 1; - padding: ${({ $hasThumbnails }) => - $hasThumbnails - ? `108px ${PAGE_HORIZONTAL_PADDING} 14px` - : `108px ${PAGE_HORIZONTAL_PADDING} 18px`}; - box-sizing: border-box; - background: linear-gradient( - to bottom, - rgb(255 255 255 / 0) 0%, - rgb(255 255 255 / 0.08) 16%, - rgb(255 255 255 / 0.24) 32%, - rgb(255 255 255 / 0.48) 52%, - rgb(255 255 255 / 0.72) 70%, - rgb(255 255 255 / 0.9) 86%, - #fff 100% - ); -`; -const ThumbnailRow = styled.div<{ $integrated: boolean }>` - margin-top: ${({ $integrated }) => ($integrated ? '12px' : 0)}; - padding: ${({ $integrated }) => - $integrated ? '0 0 4px' : `0 ${PAGE_HORIZONTAL_PADDING} 18px`}; - box-sizing: border-box; - - display: flex; - gap: 8px; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - scroll-snap-type: x proximity; - - &::-webkit-scrollbar { - display: none; - } -`; -const ThumbnailItem = styled.div<{ selected: boolean }>` - flex: 0 0 auto; - width: 42px; - height: 42px; - box-sizing: border-box; - border: 2px solid ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - border-radius: 12px; - overflow: hidden; - background: #fff; - box-shadow: 0 3px 0 ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - cursor: pointer; - scroll-snap-align: start; - transition: - transform 140ms ease, - border-color 140ms ease, - box-shadow 140ms ease; - - > * { - width: 100%; - border-radius: 9px; - } - - ${({ selected }) => - selected - ? css` - border-color: ${CSSVariable.COLOR_PRIMARY_ACTIVE}; - box-shadow: 0 3px 0 ${CSSVariable.COLOR_PRIMARY_ACTIVE}; - transform: translateY(-1px); - ` - : css` - &:active { - transform: translateY(2px); - box-shadow: 0 1px 0 ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - } - `} -`; - -function Info({ - artist, - identityRef, -}: { - artist: Artist; - identityRef?: Ref; -}) { - const { photos } = artist; - const [selectedId, setSelectedId] = useState( - () => photos[0]?.id, - ); - const [viewerPhoto, setViewerPhoto] = useState(null); - - useEffect(() => { - if (!photos.length) { - setSelectedId(undefined); - return; - } - setSelectedId((prev) => - prev && photos.some((p) => p.id === prev) ? prev : photos[0].id, - ); - }, [photos]); - - const selected = photos.find((p) => p.id === selectedId) ?? photos[0]; - const showThumbnails = photos.length > 1; - const photoLabel = selected?.description || artist.name; - const identity = ( - -

{artist.name}

- {artist.aliases.length ? ( -
- {artist.aliases.map((alias, index) => ( -
- {alias} -
- ))} -
- ) : null} -
- ); - const thumbnails = showThumbnails ? ( - - {photos.map((photo) => ( - setSelectedId(photo.id)} - aria-label={photo.description || artist.name} - > - - - ))} - - ) : null; - - return ( - - ); -} - -export default Info; diff --git a/apps/pwa/src/pages/player/artist_drawer/music_list.tsx b/apps/pwa/src/pages/player/artist_drawer/music_list.tsx deleted file mode 100644 index b13bb1ed2..000000000 --- a/apps/pwa/src/pages/player/artist_drawer/music_list.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { CSSProperties, memo, useContext } from 'react'; -import Empty from '@/components/empty'; -import styled from 'styled-components'; -import { MusicWithArtistAliases } from '../constants'; -import Music from '../components/music'; -import Context from '../context'; -import { t } from '@/i18n'; -import { PAGE_HORIZONTAL_PADDING } from '../pages/page'; - -const Root = styled.div` - padding: 12px ${PAGE_HORIZONTAL_PADDING} 16px; -`; -const emptyStyle: CSSProperties = { - padding: '50px 0', -}; - -function MusicList({ musicList }: { musicList: MusicWithArtistAliases[] }) { - const { playqueue, currentPlayqueuePosition } = useContext(Context); - return musicList.length ? ( - - {musicList.map((music, index) => ( - - ))} - - ) : ( - - ); -} - -export default memo(MusicList); diff --git a/apps/pwa/src/pages/player/artist_drawer/toolbar.tsx b/apps/pwa/src/pages/player/artist_drawer/toolbar.tsx deleted file mode 100644 index 0d902f412..000000000 --- a/apps/pwa/src/pages/player/artist_drawer/toolbar.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import styled from 'styled-components'; -import Button from '@/components/button'; -import { Tooltip } from '@/components'; -import { PlaylistAdd, Edit } from '@/components/icon'; -import { CSSVariable } from '@/global_style'; -import notice from '@/utils/notice'; -import { t } from '@/i18n'; -import { useUser } from '@/global_states/server'; -import { useSetting } from '@/global_states/setting'; -import { ROOT_PATH, ADMIN_PATH } from '@/constants/route'; -import { Artist } from './constants'; -import { CONTROLLER_FLOATING_RESERVED_HEIGHT } from '../constants'; -import { MusicWithArtistAliases } from '../constants'; -import addMusicListToPlaylist from '../add_to_playlist'; - -const Style = styled.div<{ $floatingControllerOffset: boolean }>` - z-index: 1; - - position: absolute; - left: 50%; - transform: translateX(-50%); - bottom: ${({ $floatingControllerOffset }) => - $floatingControllerOffset - ? CONTROLLER_FLOATING_RESERVED_HEIGHT - : 'calc(14px + env(safe-area-inset-bottom, 0))'}; - max-width: calc(100% - 32px); - padding: 8px 12px; - box-sizing: border-box; - - display: flex; - align-items: center; - gap: 8px; - - background: #fff; - border: 2px solid ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - border-radius: 16px; - box-shadow: - 0 4px 0 ${CSSVariable.COLOR_NEUTRAL_SHADOW}, - 0 10px 24px rgb(0 0 0 / 0.1); - - > .left { - min-width: 0; - - display: flex; - align-items: center; - gap: 8px; - } -`; - -const getArtistMusicList = (artist: Artist): MusicWithArtistAliases[] => - Array.from( - new Map( - [ - ...artist.performerMusicList, - ...artist.lyricistMusicList, - ...artist.composerMusicList, - ].map((music) => [music.id, music]), - ).values(), - ); - -function Toolbar({ - artist, - floatingControllerOffset = false, -}: { - artist: Artist; - floatingControllerOffset?: boolean; -}) { - const user = useUser(); - const adminQuickEdit = useSetting((s) => s.adminQuickEdit); - const musicList = getArtistMusicList(artist); - const hasMusic = musicList.length > 0; - // 仅在 admin 且开启「管理员快捷编辑」开关时展示编辑入口 - const showAdminEdit = !!user?.admin && adminQuickEdit; - return ( - - ); -} - -export default Toolbar; diff --git a/apps/pwa/src/pages/player/artist_drawer/use_data.ts b/apps/pwa/src/pages/player/artist_drawer/use_data.ts deleted file mode 100644 index 8a5d7c544..000000000 --- a/apps/pwa/src/pages/player/artist_drawer/use_data.ts +++ /dev/null @@ -1,69 +0,0 @@ -import logger from '@/utils/logger'; -import getArtist from '@/server/api/get_artist'; -import { useCallback, useEffect, useState } from 'react'; -import { Artist } from './constants'; -import playerEventemitter, { - EventType as PlayerEventType, -} from '../eventemitter'; - -type Data = - | { - error: null; - loading: true; - value: null; - } - | { - error: Error; - loading: false; - value: null; - } - | { - error: null; - loading: false; - value: Artist; - }; -const dataLoading: Data = { - error: null, - loading: true, - value: null, -}; - -export default (artistId: string) => { - const [data, setData] = useState(dataLoading); - const getData = useCallback(async () => { - setData(dataLoading); - try { - const artist = await getArtist(artistId); - setData({ - error: null, - loading: false, - value: artist, - }); - } catch (error) { - logger.error(error, 'Fail to get artist'); - setData({ - error, - loading: false, - value: null, - }); - } - }, [artistId]); - - useEffect(() => { - getData(); - }, [getData]); - - useEffect(() => { - const unlistenArtistUpdated = playerEventemitter.listen( - PlayerEventType.ARTIST_UPDATED, - (payload) => { - if (payload.id === artistId) { - getData(); - } - }, - ); - return unlistenArtistUpdated; - }, [getData, artistId]); - - return { data, reload: getData }; -}; diff --git a/apps/pwa/src/pages/player/artist_drawer/use_open.ts b/apps/pwa/src/pages/player/artist_drawer/use_open.ts deleted file mode 100644 index 6a3f49590..000000000 --- a/apps/pwa/src/pages/player/artist_drawer/use_open.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import useNavigate from '@/utils/use_navigate'; -import { Query } from '@/constants'; -import useQuery from '@/utils/use_query'; -import e, { EventType } from '../eventemitter'; - -export default () => { - const navigate = useNavigate(); - const onClose = useCallback( - () => - navigate({ - query: { - [Query.ARTIST_DRAWER_ID]: '', - }, - }), - [navigate], - ); - const { artist_drawer_id: urlId } = useQuery(); - const [id, setId] = useState(urlId); - - useEffect(() => { - setId((i) => urlId || i); - }, [urlId]); - - useEffect(() => { - const unlistenOpen = e.listen(EventType.OPEN_ARTIST_DRAWER, (data) => - window.setTimeout( - () => - navigate({ - query: { - [Query.ARTIST_DRAWER_ID]: data.id, - }, - }), - 0, - ), - ); - return unlistenOpen; - }, [navigate]); - - return { - id, - open: !!urlId, - onClose, - }; -}; diff --git a/apps/pwa/src/pages/player/authorized_device_drawer/index.tsx b/apps/pwa/src/pages/player/authorized_device_drawer/index.tsx index 501f5cade..77352cc66 100644 --- a/apps/pwa/src/pages/player/authorized_device_drawer/index.tsx +++ b/apps/pwa/src/pages/player/authorized_device_drawer/index.tsx @@ -43,7 +43,6 @@ function AuthorizedDeviceDrawer() { !v && onClose()}> 1 - Math.pow(1 - t, 3); +import BaseCover, { Shape } from '@/components/cover'; const Style = styled.div<{ $pressable: boolean }>` position: relative; @@ -62,24 +57,10 @@ const Style = styled.div<{ $pressable: boolean }>` ` : null} `; -const Cover = styled(animated.img)` +const CoverImage = styled(BaseCover)` ${absoluteFullSize} - - user-select: none; - -webkit-tap-highlight-color: transparent; - object-fit: cover; - object-position: center; - will-change: opacity, transform, filter; `; -type DisplayImage = { - src: string; - placeholder: boolean; -}; - -const displayImageKey = (image: DisplayImage) => - `${image.placeholder ? 'placeholder' : 'image'}:${image.src}`; - function Wrapper({ cover, placeholderCover, @@ -90,78 +71,14 @@ function Wrapper({ placeholderCover?: string; mask: boolean; } & HtmlHTMLAttributes) { - const [image, setImage] = useState(() => ({ - src: placeholderCover || PngDefaultCover, - placeholder: !!placeholderCover, - })); - - useEffect(() => { - const fallback = placeholderCover || PngDefaultCover; - const fallbackImage = { - src: fallback, - placeholder: !!placeholderCover, - }; - if (cover) { - setImage(fallbackImage); - let canceled = false; - loadImage(cover) - .then(() => { - if (!canceled) { - setImage({ src: cover, placeholder: false }); - } - }) - .catch((error) => { - logger.error(error, 'Failed to load music cover'); - if (!canceled) { - setImage(fallbackImage); - } - }); - return () => { - canceled = true; - }; - } - setImage(fallbackImage); - }, [cover, placeholderCover]); - - const transitions = useTransition(image, { - keys: displayImageKey, - from: (item) => ({ - opacity: 0, - transform: item.placeholder - ? 'scale(1.14) translate3d(-3%, 0, 0)' - : 'scale(1.12) translate3d(-3%, 0, 0)', - filter: item.placeholder - ? 'blur(8px) brightness(1.08) saturate(1.1)' - : 'brightness(1.18) saturate(1.12)', - }), - enter: (item) => ({ - opacity: 1, - transform: item.placeholder - ? 'scale(1.1) translate3d(0%, 0, 0)' - : 'scale(1.02) translate3d(0%, 0, 0)', - filter: item.placeholder - ? 'blur(8px) brightness(1.04) saturate(1.08)' - : 'brightness(1) saturate(1)', - }), - leave: (item) => ({ - opacity: 0, - transform: item.placeholder - ? 'scale(1.12) translate3d(3%, 0, 0)' - : 'scale(1.18) translate3d(3%, 0, 0)', - filter: item.placeholder - ? 'blur(6px) brightness(0.96) saturate(1.02)' - : 'brightness(0.88) saturate(0.9)', - }), - config: { - duration: 720, - easing: easeOutCubic, - }, - }); return ( diff --git a/apps/pwa/src/pages/player/music_drawer/constants.ts b/apps/pwa/src/pages/player/music_drawer/constants.ts deleted file mode 100644 index fbd318e05..000000000 --- a/apps/pwa/src/pages/player/music_drawer/constants.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Music, ArtistWithAliases } from '../constants'; - -export interface Lyric { - id: number; - lrc: string; -} - -export interface CreateUser { - id: string; - nickname: string; -} - -export interface ArtistPhoto { - id: string; - asset: string; - thumbnail?: string; - description: string; -} - -export interface ArtistDetail extends ArtistWithAliases { - avatar: string; - photos: ArtistPhoto[]; -} - -export interface RelatedPublicMusicbill { - id: string; - name: string; - cover: string; - musicCount: number; - user: CreateUser & { - avatar: string; - }; -} - -export interface MusicDetail extends Music { - heat: number; - lyrics: Lyric[]; - createTime: string; - forkFromList: Omit[]; - forkList: Omit[]; - performers: ArtistDetail[]; - lyricists: ArtistDetail[]; - composers: ArtistDetail[]; - year: number | null; - musicbillCount: number; - assetSize: number; - assetDurationMs: number; - assetCodec: string; - assetBitRate: number; - relatedPublicMusicbillList: RelatedPublicMusicbill[]; -} diff --git a/apps/pwa/src/pages/player/music_drawer/content.tsx b/apps/pwa/src/pages/player/music_drawer/content.tsx deleted file mode 100644 index a3d30ed3e..000000000 --- a/apps/pwa/src/pages/player/music_drawer/content.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/features/player/drawers/music_drawer/content'; diff --git a/apps/pwa/src/pages/player/music_drawer/index.tsx b/apps/pwa/src/pages/player/music_drawer/index.tsx deleted file mode 100644 index 1383863b5..000000000 --- a/apps/pwa/src/pages/player/music_drawer/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/features/player/drawers/music_drawer'; diff --git a/apps/pwa/src/pages/player/music_drawer/info.tsx b/apps/pwa/src/pages/player/music_drawer/info.tsx deleted file mode 100644 index 6117ffa88..000000000 --- a/apps/pwa/src/pages/player/music_drawer/info.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { type Ref } from 'react'; -import { CSSVariable } from '@/global_style'; -import styled from 'styled-components'; -import { MusicDetail } from './constants'; -import { t } from '@/i18n'; -import { PAGE_HORIZONTAL_PADDING } from '../pages/page'; - -const Style = styled.section<{ $showTitle: boolean }>` - padding: ${({ $showTitle }) => - $showTitle - ? `22px ${PAGE_HORIZONTAL_PADDING} 0` - : `18px ${PAGE_HORIZONTAL_PADDING} 0`}; - - > .headline { - margin-bottom: 16px; - - > .name { - margin: 0; - - font-family: 'Nunito', 'Varela Round', system-ui, sans-serif; - font-size: 28px; - font-weight: 800; - line-height: 1.15; - letter-spacing: 0; - color: rgb(50 50 50); - overflow-wrap: anywhere; - } - - > .aliases { - margin: 8px 0 0; - - display: flex; - flex-direction: column; - gap: 3px; - - font-size: ${CSSVariable.TEXT_SIZE_NORMAL}; - font-weight: 600; - color: ${CSSVariable.TEXT_COLOR_SECONDARY}; - line-height: 1.3; - } - - > .meta { - margin-top: 10px; - - color: rgb(145 145 145); - font-family: 'Nunito', 'Varela Round', system-ui, sans-serif; - font-size: 13px; - font-weight: 800; - line-height: 1.35; - overflow-wrap: anywhere; - } - } -`; - -const formatDurationMs = (durationMs: number) => { - const totalSeconds = Math.round(durationMs / 1000); - const minute = Math.floor(totalSeconds / 60); - const second = totalSeconds % 60; - return `${minute > 9 ? minute : `0${minute}`}:${ - second > 9 ? second : `0${second}` - }`; -}; -const formatFileSize = (size: number) => { - if (size < 1024) { - return `${size}B`; - } - if (size < 1024 * 1024) { - return `${Math.round(size / 1024)}KB`; - } - return `${(size / 1024 / 1024).toFixed(2)}MB`; -}; -const formatBitRate = (bitRate: number) => `${Math.round(bitRate / 1000)}kbps`; - -function MusicMetaLine({ music }: { music: MusicDetail }) { - const metaList = [ - music.assetDurationMs ? formatDurationMs(music.assetDurationMs) : '', - music.year ? `${music.year}` : '', - t('heat', music.heat.toString()), - t('musicbill_count', music.musicbillCount.toString()), - music.assetCodec ? music.assetCodec.toUpperCase() : '', - music.assetBitRate ? formatBitRate(music.assetBitRate) : '', - music.assetSize ? formatFileSize(music.assetSize) : '', - ].filter(Boolean); - - return metaList.length ? ( -
{metaList.join(' · ')}
- ) : null; -} - -function Info({ - music, - showTitle = true, - titleRef, -}: { - music: MusicDetail; - showTitle?: boolean; - titleRef?: Ref; -}) { - if (!showTitle) { - return null; - } - - return ( - - ); -} - -export default Info; diff --git a/apps/pwa/src/pages/player/music_drawer/lyric.tsx b/apps/pwa/src/pages/player/music_drawer/lyric.tsx deleted file mode 100644 index bb1f30d56..000000000 --- a/apps/pwa/src/pages/player/music_drawer/lyric.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { MusicType } from '@/constants/music'; -import { CSSVariable } from '@/global_style'; -import { MultipleLrc } from 'react-lrc'; -import styled from 'styled-components'; -import { saveAs } from 'file-saver'; -import { t } from '@/i18n'; -import capitalize from '@/style/capitalize'; -import formatMusicFilename from '@/utils/format_music_filename'; -import { useUser } from '@/global_states/server'; -import { useSetting } from '@/global_states/setting'; -import { MusicDetail } from './constants'; -import { PAGE_HORIZONTAL_PADDING } from '../pages/page'; -import { FileDownload } from '@/components/icon'; - -const Style = styled.section` - margin: 22px ${PAGE_HORIZONTAL_PADDING} 4px; - - > .label { - margin-bottom: 10px; - - color: rgb(75 75 75); - font-family: 'Nunito', 'Varela Round', system-ui, sans-serif; - font-size: ${CSSVariable.TEXT_SIZE_NORMAL}; - font-weight: 800; - line-height: 1.2; - ${capitalize} - } - - > .content { - position: relative; - padding: 8px 0 12px; - - background: #fff; - border: 2px solid ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - border-radius: 14px; - box-shadow: 0 4px 0 ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - } -`; -const Line = styled.div` - margin: 10px 16px; - - line-height: 1.4; - font-size: ${CSSVariable.TEXT_SIZE_NORMAL}; - font-weight: 600; - color: rgb(120 120 120); -`; -// Duolingo 风格小按钮: 纯色填充 + 同色硬阴影(无 blur), 按下 translateY 抹平阴影 -const DOWNLOAD_BTN_OFFSET = 2; -const DownloadButton = styled.button` - position: absolute; - right: 10px; - bottom: ${10 + DOWNLOAD_BTN_OFFSET}px; - - width: 22px; - height: 22px; - padding: 0; - margin: 0; - - display: inline-flex; - align-items: center; - justify-content: center; - - color: rgb(120 120 120); - background: #fff; - border: 2px solid ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - border-radius: 8px; - box-shadow: 0 ${DOWNLOAD_BTN_OFFSET}px 0 ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - font-size: 13px; - line-height: 1; - cursor: pointer; - user-select: none; - -webkit-tap-highlight-color: transparent; - will-change: transform, box-shadow; - appearance: none; - -webkit-appearance: none; - - opacity: 0.4; - transition: - opacity 160ms ease, - transform 150ms ease-out, - box-shadow 150ms ease-out, - filter 120ms; - - &:hover, - &:focus-visible { - opacity: 1; - } - - &:hover { - filter: brightness(1.04); - } - - &:active { - transform: translateY(${DOWNLOAD_BTN_OFFSET}px); - box-shadow: none; - transition: - transform 60ms ease-in, - box-shadow 60ms ease-in, - filter 60ms; - } - - &:focus-visible { - outline: 2px solid ${CSSVariable.COLOR_CONTROL_NEUTRAL}; - outline-offset: 2px; - } -`; - -function Lyric({ music }: { music: MusicDetail }) { - const user = useUser(); - const adminQuickEdit = useSetting((s) => s.adminQuickEdit); - - // 乐曲或没有歌词的歌曲, 不展示歌词模块 - if (music.type !== MusicType.SONG || music.lyrics.length === 0) { - return null; - } - - // 下载按钮归入「管理员快捷编辑」开关 - const downloadable = !!user?.admin && adminQuickEdit; - - const downloadLyrics = () => { - const performerNames = music.performers.map((s) => s.name); - // 单条歌词不加 (n) 后缀, 多条则按 1..N 顺序追加 - const multiple = music.lyrics.length > 1; - music.lyrics.forEach((lyric, i) => { - const filename = formatMusicFilename({ - name: music.name, - performerNames, - ext: 'lrc', - index: multiple ? i + 1 : undefined, - }); - saveAs( - new Blob([lyric.lrc], { type: 'text/plain;charset=utf-8' }), - filename, - ); - }); - }; - - return ( - - ); -} - -export default Lyric; diff --git a/apps/pwa/src/pages/player/music_drawer/music_drawer.tsx b/apps/pwa/src/pages/player/music_drawer/music_drawer.tsx deleted file mode 100644 index 0335c1b88..000000000 --- a/apps/pwa/src/pages/player/music_drawer/music_drawer.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import AppDrawer from '@/components/app_drawer'; -import MusicContent from './content'; - -function MusicDrawer({ - id, - open, - onClose, - zIndex, -}: { - id: string; - open: boolean; - onClose: () => void; - zIndex: number; -}) { - return ( - - - - ); -} - -export default MusicDrawer; diff --git a/apps/pwa/src/pages/player/music_drawer/performer_list/index.tsx b/apps/pwa/src/pages/player/music_drawer/performer_list/index.tsx deleted file mode 100644 index 829ce1816..000000000 --- a/apps/pwa/src/pages/player/music_drawer/performer_list/index.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import styled from 'styled-components'; -import { CSSVariable } from '@/global_style'; -import capitalize from '@/style/capitalize'; -import { ArtistDetail } from '../constants'; -import Performer from './performer'; -import { PAGE_HORIZONTAL_PADDING } from '../../pages/page'; - -const Style = styled.div` - margin: 20px ${PAGE_HORIZONTAL_PADDING}; - - > .label { - margin-bottom: 10px; - - color: rgb(75 75 75); - font-family: 'Nunito', 'Varela Round', system-ui, sans-serif; - font-size: ${CSSVariable.TEXT_SIZE_NORMAL}; - font-weight: 800; - line-height: 1.2; - ${capitalize} - } - - > .list { - display: flex; - flex-direction: column; - gap: 10px; - } -`; - -function ArtistList({ - label, - artistList, -}: { - label: string; - artistList: ArtistDetail[]; -}) { - if (!artistList.length) { - return null; - } - - return ( - - ); -} - -export default ArtistList; diff --git a/apps/pwa/src/pages/player/music_drawer/performer_list/performer.tsx b/apps/pwa/src/pages/player/music_drawer/performer_list/performer.tsx deleted file mode 100644 index b0930dbd1..000000000 --- a/apps/pwa/src/pages/player/music_drawer/performer_list/performer.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import Avatar from '@/components/avatar'; -import { CSSVariable } from '@/global_style'; -import styled from 'styled-components'; -import ellipsis from '@/style/ellipsis'; -import getResizedImage from '@/server/asset/get_resized_image'; -import { ArtistDetail } from '../constants'; -import e, { EventType } from '../../eventemitter'; - -const AVATAR_SIZE = 32; -const Style = styled.div` - display: flex; - align-items: center; - gap: 10px; - - min-height: 56px; - padding: 8px 12px 12px; - - transition: - transform 120ms ease-out, - box-shadow 120ms ease-out, - filter 120ms ease-out; - cursor: pointer; - background: #fff; - border: 2px solid ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - border-radius: 14px; - box-shadow: 0 4px 0 ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - - > .name { - flex: 1; - min-width: 0; - - ${ellipsis} - font-family: 'Nunito', 'Varela Round', system-ui, sans-serif; - font-size: ${CSSVariable.TEXT_SIZE_LARGE}; - font-weight: 800; - color: rgb(75 75 75); - } - - &:hover { - transform: translateY(-2px); - box-shadow: 0 6px 0 ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - } - - &:active { - transform: translateY(4px); - box-shadow: none; - } -`; - -function Performer({ performer }: { performer: ArtistDetail }) { - return ( - - ); -} - -export default Performer; diff --git a/apps/pwa/src/pages/player/music_drawer/related_public_musicbill_list.tsx b/apps/pwa/src/pages/player/music_drawer/related_public_musicbill_list.tsx deleted file mode 100644 index 702c6bab4..000000000 --- a/apps/pwa/src/pages/player/music_drawer/related_public_musicbill_list.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import { useMemo, type KeyboardEvent } from 'react'; -import styled from 'styled-components'; -import Cover, { Shape } from '@/components/cover'; -import { CSSVariable } from '@/global_style'; -import ellipsis from '@/style/ellipsis'; -import capitalize from '@/style/capitalize'; -import getResizedImage from '@/server/asset/get_resized_image'; -import { t } from '@/i18n'; -import { RelatedPublicMusicbill } from './constants'; -import { PAGE_HORIZONTAL_PADDING } from '../pages/page'; -import playerEventemitter, { - EventType as PlayerEventType, -} from '../eventemitter'; - -const RELATED_PUBLIC_MUSICBILL_LIMIT = 5; -const COVER_IMAGE_SIZE = 72; - -const Style = styled.section` - margin: 22px ${PAGE_HORIZONTAL_PADDING} 4px; - - > .label { - margin-bottom: 10px; - - color: rgb(75 75 75); - font-family: 'Nunito', 'Varela Round', system-ui, sans-serif; - font-size: ${CSSVariable.TEXT_SIZE_NORMAL}; - font-weight: 800; - line-height: 1.2; - ${capitalize} - } - - > .list { - display: flex; - flex-direction: column; - gap: 10px; - } -`; -const Item = styled.div` - min-height: 72px; - padding: 8px 10px 12px; - box-sizing: border-box; - - display: flex; - align-items: center; - gap: 12px; - - background: #fff; - border: 2px solid ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - border-radius: 14px; - box-shadow: 0 4px 0 ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - cursor: pointer; - user-select: none; - transition: - transform 120ms ease-out, - box-shadow 120ms ease-out, - filter 120ms ease-out; - - > .cover-frame { - flex: 0 0 58px; - width: 58px; - height: 58px; - padding: 2px; - box-sizing: border-box; - - background: #fff; - border: 2px solid ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - border-radius: 10px; - box-shadow: 0 2px 0 ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - } - - > .cover-frame > .cover { - width: 100%; - height: 100%; - border-radius: 6px; - } - - > .info { - flex: 1; - min-width: 0; - } - - > .info > .name { - color: rgb(55 55 55); - font-family: 'Nunito', 'Varela Round', system-ui, sans-serif; - font-size: ${CSSVariable.TEXT_SIZE_LARGE}; - font-weight: 800; - line-height: 1.25; - ${ellipsis} - } - - > .info > .owner { - margin-top: 3px; - - color: ${CSSVariable.TEXT_COLOR_SECONDARY}; - font-family: 'Nunito', 'Varela Round', system-ui, sans-serif; - font-size: ${CSSVariable.TEXT_SIZE_SMALL}; - font-weight: 800; - line-height: 1.3; - ${ellipsis} - } - - > .info > .owner > button { - padding: 0; - border: 0; - background: transparent; - color: inherit; - font: inherit; - cursor: pointer; - - &:hover { - color: ${CSSVariable.TEXT_COLOR_PRIMARY}; - } - } - - > .count { - flex: 0 0 48px; - min-width: 48px; - padding-left: 10px; - box-sizing: border-box; - - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - - border-left: 2px solid ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - color: ${CSSVariable.TEXT_COLOR_SECONDARY}; - font-family: 'Nunito', 'Varela Round', system-ui, sans-serif; - line-height: 1.05; - } - - > .count > .value { - color: ${CSSVariable.TEXT_COLOR_PRIMARY}; - font-size: ${CSSVariable.TEXT_SIZE_LARGE}; - font-weight: 900; - } - - > .count > .label { - margin-top: 3px; - - font-size: ${CSSVariable.TEXT_SIZE_SMALL}; - font-weight: 800; - text-transform: capitalize; - } - - &:hover { - background: #fff; - } - - &:active { - background: #fff; - transform: translateY(4px); - box-shadow: none; - } - - &:focus-visible { - outline: 3px solid rgb(255 184 28); - outline-offset: 3px; - } -`; - -function sampleMusicbillList(musicbillList: RelatedPublicMusicbill[]) { - if (musicbillList.length <= RELATED_PUBLIC_MUSICBILL_LIMIT) { - return musicbillList; - } - - // 后端按 5 个随机抽样返回;这里兜底处理超量数据, 保证 drawer 展示数量稳定。 - const pool = [...musicbillList]; - for (let i = 0; i < RELATED_PUBLIC_MUSICBILL_LIMIT; i += 1) { - const randomIndex = i + Math.floor(Math.random() * (pool.length - i)); - [pool[i], pool[randomIndex]] = [pool[randomIndex], pool[i]]; - } - return pool.slice(0, RELATED_PUBLIC_MUSICBILL_LIMIT); -} - -const openMusicbillDrawer = (id: string) => - playerEventemitter.emit(PlayerEventType.OPEN_MUSICBILL_DRAWER, { id }); - -const openUserDrawer = (id: string) => - playerEventemitter.emit(PlayerEventType.OPEN_USER_DRAWER, { id }); - -function RelatedPublicMusicbillList({ - musicbillList, -}: { - musicbillList: RelatedPublicMusicbill[]; -}) { - const sampledMusicbillList = useMemo( - () => sampleMusicbillList(musicbillList), - [musicbillList], - ); - - if (!sampledMusicbillList.length) { - return null; - } - - const handleItemKeyDown = ( - event: KeyboardEvent, - id: string, - ) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - openMusicbillDrawer(id); - } - }; - - return ( - - ); -} - -export default RelatedPublicMusicbillList; diff --git a/apps/pwa/src/pages/player/music_drawer/sub_music_list.tsx b/apps/pwa/src/pages/player/music_drawer/sub_music_list.tsx deleted file mode 100644 index 6acd8335b..000000000 --- a/apps/pwa/src/pages/player/music_drawer/sub_music_list.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { CSSVariable } from '@/global_style'; -import styled from 'styled-components'; -import getResizedImage from '@/server/asset/get_resized_image'; -import { Music } from '../constants'; -import MusicInfo from '../components/music_info'; -import capitalize from '@/style/capitalize'; -import { PAGE_HORIZONTAL_PADDING } from '../pages/page'; - -const Style = styled.div` - margin: 22px ${PAGE_HORIZONTAL_PADDING}; - - > .label { - margin-bottom: 10px; - - color: rgb(75 75 75); - font-family: 'Nunito', 'Varela Round', system-ui, sans-serif; - font-size: ${CSSVariable.TEXT_SIZE_NORMAL}; - font-weight: 800; - line-height: 1.2; - ${capitalize} - } - - > .list { - display: flex; - flex-direction: column; - gap: 10px; - - > .item { - min-height: 64px; - padding: 8px 10px 12px; - - background: #fff; - border: 2px solid ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - border-radius: 14px; - box-shadow: 0 4px 0 ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - transition: - transform 120ms ease-out, - box-shadow 120ms ease-out; - - &:hover { - background: #fff; - } - - &:active { - background: #fff; - transform: translateY(4px); - box-shadow: none; - } - - > div:first-child { - box-sizing: border-box; - background: #fff; - border: 2px solid ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - border-radius: 10px; - box-shadow: 0 2px 0 ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - } - - > .info { - > .name { - font-family: 'Nunito', 'Varela Round', system-ui, sans-serif; - font-weight: 800; - color: rgb(55 55 55); - } - - > .performers { - font-family: 'Nunito', 'Varela Round', system-ui, sans-serif; - font-weight: 700; - } - } - } - } -`; - -function SubMusicList({ - musicList, - label, -}: { - musicList: Omit[]; - label: string; -}) { - return ( - - ); -} - -export default SubMusicList; diff --git a/apps/pwa/src/pages/player/music_drawer/toolbar.tsx b/apps/pwa/src/pages/player/music_drawer/toolbar.tsx deleted file mode 100644 index c0bc8d74e..000000000 --- a/apps/pwa/src/pages/player/music_drawer/toolbar.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import styled from 'styled-components'; -import { useContext } from 'react'; -import Button from '@/components/button'; -import { Tooltip } from '@/components'; -import { CSSVariable } from '@/global_style'; -import { - FileDownload, - PlaylistAdd, - PostAdd, - QueueInsert, - PlayArrow, - Edit, -} from '@/components/icon'; -import { useUser } from '@/global_states/server'; -import { useSetting } from '@/global_states/setting'; -import { ADMIN_PATH, ROOT_PATH } from '@/constants/route'; -import { MusicDetail } from './constants'; -import playerEventemitter, { - EventType as PlayerEventType, -} from '../eventemitter'; -import { CONTROLLER_FLOATING_RESERVED_HEIGHT } from '../constants'; -import addMusicListToPlaylist from '../add_to_playlist'; -import { t } from '@/i18n'; -import Context from '../context'; -import downloadOriginalMusicFile from '@/features/player/download_music'; - -const Style = styled.div<{ $floatingControllerOffset: boolean }>` - z-index: 1; - - position: absolute; - left: 50%; - transform: translateX(-50%); - bottom: ${({ $floatingControllerOffset }) => - $floatingControllerOffset - ? CONTROLLER_FLOATING_RESERVED_HEIGHT - : 'calc(14px + env(safe-area-inset-bottom, 0))'}; - max-width: calc(100% - 32px); - padding: 8px 12px; - box-sizing: border-box; - - display: flex; - align-items: center; - gap: 8px; - - background: #fff; - border: 2px solid ${CSSVariable.COLOR_NEUTRAL_SHADOW}; - border-radius: 16px; - box-shadow: - 0 4px 0 ${CSSVariable.COLOR_NEUTRAL_SHADOW}, - 0 10px 24px rgb(0 0 0 / 0.1); - - > .left { - min-width: 0; - - display: flex; - align-items: center; - gap: 8px; - } -`; - -function Toolbar({ - music, - floatingControllerOffset = false, -}: { - music: MusicDetail; - floatingControllerOffset?: boolean; -}) { - const user = useUser(); - const adminQuickEdit = useSetting((s) => s.adminQuickEdit); - const { downloadEnabled } = useContext(Context); - // 编辑按钮: 管理员开启「管理员快捷编辑」时才出现, 点击跳转到管理页并自动打开该音乐的编辑 drawer - const showAdminEdit = !!user?.admin && adminQuickEdit; - return ( - - ); -} - -export default Toolbar; diff --git a/apps/pwa/src/pages/player/music_drawer/use_data.ts b/apps/pwa/src/pages/player/music_drawer/use_data.ts deleted file mode 100644 index 549ef0adb..000000000 --- a/apps/pwa/src/pages/player/music_drawer/use_data.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import getMusicRequest from '@/server/api/get_music'; -import { MusicType } from '@/constants/music'; -import getLyricList from '@/server/api/get_lyric_list'; -import day from '@/utils/day'; -import { MusicDetail, Lyric } from './constants'; -import playerEventemitter, { - EventType as PlayerEventType, -} from '../eventemitter'; - -interface Data { - error: Error | null; - loading: boolean; - music: MusicDetail | null; -} -const dataLoading: Data = { - error: null, - loading: true, - music: null, -}; - -async function loadMusicDetail(id: string): Promise { - const music = await getMusicRequest({ id }); - let lyrics: Lyric[] = []; - if (music.type === MusicType.SONG) { - lyrics = await getLyricList({ musicId: music.id }); - } - - return { - ...music, - lyrics, - createTime: day(music.createTimestamp).format('YYYY-MM-DD'), - heat: music.heat, - }; -} - -export default (id: string) => { - const [data, setData] = useState(dataLoading); - - const getMusic = useCallback(async () => { - setData(dataLoading); - try { - const music = await loadMusicDetail(id); - setData({ - error: null, - loading: false, - music, - }); - } catch (error) { - setData({ - error, - loading: false, - music: null, - }); - } - }, [id]); - - useEffect(() => { - getMusic(); - }, [getMusic]); - - useEffect(() => { - const unlistenArtistUpdated = playerEventemitter.listen( - PlayerEventType.ARTIST_UPDATED, - (payload) => { - const artists = [ - ...(data.music?.performers ?? []), - ...(data.music?.lyricists ?? []), - ...(data.music?.composers ?? []), - ]; - if (artists.find((artist) => artist.id === payload.id)) { - getMusic(); - } - }, - ); - return unlistenArtistUpdated; - }, [data.music, getMusic]); - - return { data, reload: getMusic }; -}; diff --git a/apps/pwa/src/pages/player/music_drawer/use_open.ts b/apps/pwa/src/pages/player/music_drawer/use_open.ts deleted file mode 100644 index 47e8986b4..000000000 --- a/apps/pwa/src/pages/player/music_drawer/use_open.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import useNavigate from '@/utils/use_navigate'; -import { Query } from '@/constants'; -import useQuery from '@/utils/use_query'; -import eventemitter, { EventType } from '../eventemitter'; - -export default () => { - const navigate = useNavigate(); - const onClose = useCallback( - () => - navigate({ - query: { - [Query.MUSIC_DRAWER_ID]: '', - }, - }), - [navigate], - ); - const { music_drawer_id: urlId } = useQuery(); - const [id, setId] = useState(urlId); - - useEffect(() => { - setId((i) => urlId || i); - }, [urlId]); - - useEffect(() => { - const unlistenOpenMusicDrawer = eventemitter.listen( - EventType.OPEN_MUSIC_DRAWER, - (data) => - window.setTimeout( - () => - navigate({ - query: { - [Query.MUSIC_DRAWER_ID]: data.id, - }, - }), - 0, - ), - ); - return unlistenOpenMusicDrawer; - }, [navigate]); - - return { - open: !!urlId, - onClose, - id, - }; -}; diff --git a/apps/pwa/src/pages/player/musicbill_drawer/constants.ts b/apps/pwa/src/pages/player/musicbill_drawer/constants.ts deleted file mode 100644 index 96e1d21ff..000000000 --- a/apps/pwa/src/pages/player/musicbill_drawer/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '@/features/player/drawers/musicbill_drawer/constants'; diff --git a/apps/pwa/src/pages/player/musicbill_drawer/eventemitter.ts b/apps/pwa/src/pages/player/musicbill_drawer/eventemitter.ts deleted file mode 100644 index 1a73fb806..000000000 --- a/apps/pwa/src/pages/player/musicbill_drawer/eventemitter.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from '@/features/player/drawers/musicbill_drawer/eventemitter'; -export * from '@/features/player/drawers/musicbill_drawer/eventemitter'; diff --git a/apps/pwa/src/pages/player/musicbill_drawer/index.tsx b/apps/pwa/src/pages/player/musicbill_drawer/index.tsx deleted file mode 100644 index 527906848..000000000 --- a/apps/pwa/src/pages/player/musicbill_drawer/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/features/player/drawers/musicbill_drawer'; diff --git a/apps/pwa/src/pages/player/musicbill_drawer/info.tsx b/apps/pwa/src/pages/player/musicbill_drawer/info.tsx deleted file mode 100644 index 5d5f31697..000000000 --- a/apps/pwa/src/pages/player/musicbill_drawer/info.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/features/player/drawers/musicbill_drawer/info'; diff --git a/apps/pwa/src/pages/player/musicbill_drawer/music_list.tsx b/apps/pwa/src/pages/player/musicbill_drawer/music_list.tsx deleted file mode 100644 index fe57685ba..000000000 --- a/apps/pwa/src/pages/player/musicbill_drawer/music_list.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/features/player/drawers/musicbill_drawer/music_list'; diff --git a/apps/pwa/src/pages/player/musicbill_drawer/musicbill_drawer.tsx b/apps/pwa/src/pages/player/musicbill_drawer/musicbill_drawer.tsx deleted file mode 100644 index 8a74445f6..000000000 --- a/apps/pwa/src/pages/player/musicbill_drawer/musicbill_drawer.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/features/player/drawers/musicbill_drawer/musicbill_drawer'; diff --git a/apps/pwa/src/pages/player/musicbill_drawer/toolbar.tsx b/apps/pwa/src/pages/player/musicbill_drawer/toolbar.tsx deleted file mode 100644 index 1dd5362ff..000000000 --- a/apps/pwa/src/pages/player/musicbill_drawer/toolbar.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/features/player/drawers/musicbill_drawer/toolbar'; diff --git a/apps/pwa/src/pages/player/musicbill_drawer/use_data.ts b/apps/pwa/src/pages/player/musicbill_drawer/use_data.ts deleted file mode 100644 index 4750164ec..000000000 --- a/apps/pwa/src/pages/player/musicbill_drawer/use_data.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/features/player/drawers/musicbill_drawer/use_data'; diff --git a/apps/pwa/src/pages/player/musicbill_music_drawer/constants.ts b/apps/pwa/src/pages/player/musicbill_music_drawer/constants.ts deleted file mode 100644 index 6581ac5d6..000000000 --- a/apps/pwa/src/pages/player/musicbill_music_drawer/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const FLOATING_GAP = 12; diff --git a/apps/pwa/src/pages/player/musicbill_music_drawer/index.tsx b/apps/pwa/src/pages/player/musicbill_music_drawer/index.tsx deleted file mode 100644 index dd2ea3682..000000000 --- a/apps/pwa/src/pages/player/musicbill_music_drawer/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/features/player/drawers/musicbill_music_drawer'; diff --git a/apps/pwa/src/pages/player/musicbill_music_drawer/musicbill.tsx b/apps/pwa/src/pages/player/musicbill_music_drawer/musicbill.tsx deleted file mode 100644 index 24024e7b9..000000000 --- a/apps/pwa/src/pages/player/musicbill_music_drawer/musicbill.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import { memo } from 'react'; -import styled from 'styled-components'; -import { CSSVariable } from '@/global_style'; -import { CSS_VAR } from '@/components/theme'; -import { Refresh, Check } from '@/components/icon'; -import { RequestStatus } from '@/constants'; -import Spinner from '@/components/spinner'; -import ellipsis from '@/style/ellipsis'; -import getResizedImage from '@/server/asset/get_resized_image'; -import { - MusicWithArtistAliases, - Musicbill as MusicbillType, -} from '../constants'; -import playerEventemitter, { - EventType as PlayerEventType, -} from '../eventemitter'; -import MusicbillCover from '../components/musicbill_cover'; - -const ICON_SIZE = 24; -const PRIMARY = `var(${CSS_VAR.colorPrimary})`; -const PRIMARY_SHADOW = `var(${CSS_VAR.colorPrimaryShadow})`; -const PUBLIC = '#63d1fa'; -const PUBLIC_SHADOW = 'rgb(72 179 220)'; -const NEUTRAL_SHADOW = CSSVariable.COLOR_NEUTRAL_SHADOW; -const CONTROL_NEUTRAL = CSSVariable.COLOR_NEUTRAL_SHADOW; -const COVER_SIZE = 28; -const COVER_INNER_SIZE = COVER_SIZE - 4; -const CHECKBOX_SIZE = 24; - -const Style = styled.div<{ $public: boolean; $selected: boolean }>` - min-height: 48px; - padding: 10px; - margin: 0 12px 10px; - - display: flex; - align-items: center; - gap: 10px; - - cursor: pointer; - color: ${CSSVariable.TEXT_COLOR_PRIMARY}; - background: ${({ $selected }) => ($selected ? PRIMARY : '#fff')}; - border: 2px solid - ${({ $selected }) => - $selected ? PRIMARY_SHADOW : CSSVariable.COLOR_BORDER}; - border-radius: 14px; - box-shadow: 0 4px 0 - ${({ $selected }) => ($selected ? PRIMARY_SHADOW : NEUTRAL_SHADOW)}; - user-select: none; - -webkit-tap-highlight-color: transparent; - transition: - transform 150ms ease-out, - box-shadow 150ms ease-out, - border-color 150ms ease-out, - background 150ms ease-out, - color 150ms ease-out, - filter 120ms ease-out; - - &:hover { - filter: brightness(1.04); - } - - &:active { - transform: translateY(4px); - box-shadow: none; - transition: - transform 60ms ease-in, - box-shadow 60ms ease-in, - filter 60ms ease-in; - } - - > .icon { - width: ${ICON_SIZE}px; - height: ${ICON_SIZE}px; - flex: 0 0 auto; - color: ${({ $selected }) => - $selected ? '#fff' : CSSVariable.TEXT_COLOR_SECONDARY}; - } - - > .cover { - flex: 0 0 auto; - width: ${COVER_SIZE}px; - height: ${COVER_SIZE}px; - box-sizing: border-box; - - display: flex; - align-items: center; - justify-content: center; - - overflow: hidden; - background: #fff; - border: 2px solid - ${({ $public, $selected }) => - $public - ? PUBLIC - : $selected - ? '#fff' - : CSSVariable.COLOR_BORDER}; - border-radius: 9px; - box-shadow: 0 3px 0 - ${({ $public, $selected }) => - $public - ? PUBLIC_SHADOW - : $selected - ? 'rgb(255 255 255 / 0.42)' - : NEUTRAL_SHADOW}; - } - - > .name { - flex: 1; - min-width: 0; - - font-family: 'Nunito', 'Varela Round', system-ui, sans-serif; - font-size: ${CSSVariable.TEXT_SIZE_NORMAL}; - font-weight: 800; - letter-spacing: 0; - color: ${({ $selected }) => - $selected ? '#fff' : CSSVariable.TEXT_COLOR_PRIMARY}; - ${ellipsis} - } -`; -const Checkbox = styled.span<{ $checked: boolean }>` - flex: 0 0 auto; - width: ${CHECKBOX_SIZE}px; - height: ${CHECKBOX_SIZE}px; - box-sizing: border-box; - - display: flex; - align-items: center; - justify-content: center; - - color: #fff; - background: ${({ $checked }) => ($checked ? PRIMARY : '#fff')}; - border: 2px solid - ${({ $checked }) => ($checked ? PRIMARY_SHADOW : CONTROL_NEUTRAL)}; - border-radius: 8px; - box-shadow: 0 3px 0 - ${({ $checked }) => ($checked ? PRIMARY_SHADOW : CONTROL_NEUTRAL)}; - transition: - background 150ms ease-out, - border-color 150ms ease-out, - box-shadow 150ms ease-out, - transform 150ms ease-out; - - > svg { - width: 16px; - height: 16px; - opacity: 1; - transform: scale(${({ $checked }) => ($checked ? 1 : 0.72)}); - transition: - opacity 120ms ease-out, - transform 150ms cubic-bezier(0.34, 1.56, 0.64, 1); - } - - > .empty-check { - opacity: 0; - } - - > .refresh-icon { - color: ${CSSVariable.TEXT_COLOR_SECONDARY}; - transform: scale(1); - } -`; - -function Musicbill({ - musicbill, - music, -}: { - musicbill: MusicbillType; - music: MusicWithArtistAliases; -}) { - const { id, status, musicList } = musicbill; - const selected = - status === RequestStatus.SUCCESS && - musicList.some((m) => m.id === music.id); - - return ( - - ); -} - -export default memo(Musicbill); diff --git a/apps/pwa/src/pages/player/musicbill_music_drawer/musicbill_list.tsx b/apps/pwa/src/pages/player/musicbill_music_drawer/musicbill_list.tsx deleted file mode 100644 index 97d20b1bb..000000000 --- a/apps/pwa/src/pages/player/musicbill_music_drawer/musicbill_list.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import Spinner from '@/components/spinner'; -import { flexCenter } from '@/style/flexbox'; -import { memo, useContext } from 'react'; -import styled from 'styled-components'; -import ErrorCard from '@/components/error_card'; -import { RequestStatus } from '@/constants'; -import Empty from '@/components/empty'; -import { t } from '@/i18n'; -import { MusicWithArtistAliases } from '../constants'; -import Context from '../context'; -import playerEventemitter, { - EventType as PlayerEventType, -} from '../eventemitter'; -import { FLOATING_GAP } from './constants'; -import Musicbill from './musicbill'; - -const reloadMusicbillList = () => - playerEventemitter.emit(PlayerEventType.RELOAD_MUSICBILL_LIST, { - silence: false, - }); -const Style = styled.div` - padding: ${FLOATING_GAP}px 0 max(env(safe-area-inset-bottom, 0) + 80px) 0; -`; -const StatusContainer = styled.div` - ${flexCenter} - padding: 30px 0; -`; - -function MusicbillList({ music }: { music: MusicWithArtistAliases }) { - const { getMusicbillListStatus, musicbillList } = useContext(Context); - if (getMusicbillListStatus === RequestStatus.SUCCESS) { - if (musicbillList.length) { - return ( - - ); - } - return ( - - - - ); - } - if (getMusicbillListStatus === RequestStatus.ERROR) { - return ( - - - - ); - } - return ( - - - - ); -} - -export default memo(MusicbillList); diff --git a/apps/pwa/src/pages/player/musicbill_music_drawer/musicbill_music_drawer.tsx b/apps/pwa/src/pages/player/musicbill_music_drawer/musicbill_music_drawer.tsx deleted file mode 100644 index d411b0aab..000000000 --- a/apps/pwa/src/pages/player/musicbill_music_drawer/musicbill_music_drawer.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { memo } from 'react'; -import { Drawer, DrawerContent } from '@/components'; -import Button from '@/components/button'; -import { AddBox } from '@/components/icon'; -import styled from 'styled-components'; -import autoScrollbar from '@/style/auto_scrollbar'; -import { MusicWithArtistAliases } from '../constants'; -import { t } from '@/i18n'; -import { openCreateMusicbillDialog } from '../utils'; -import Top from './top'; -import MusicbillList from './musicbill_list'; - -const Content = styled.div` - position: relative; - height: 100%; - - display: flex; - flex-direction: column; - overflow: hidden; -`; -const ScrollContent = styled.div` - flex: 1; - min-height: 0; - - overflow: auto; - ${autoScrollbar} -`; -const FloatingCreateButton = styled(Button)` - position: absolute; - right: 16px; - bottom: max(env(safe-area-inset-bottom, 0) + 16px, 16px); - z-index: 2; -`; - -function MusicbillMusicDrawer({ - open, - onClose, - music, - zIndex, -}: { - open: boolean; - onClose: () => void; - music: MusicWithArtistAliases; - zIndex: number; -}) { - return ( - !v && onClose()}> - - - - - - - - - - - - - ); -} - -export default memo(MusicbillMusicDrawer); diff --git a/apps/pwa/src/pages/player/musicbill_music_drawer/top.tsx b/apps/pwa/src/pages/player/musicbill_music_drawer/top.tsx deleted file mode 100644 index d6bbe2f3e..000000000 --- a/apps/pwa/src/pages/player/musicbill_music_drawer/top.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import styled from 'styled-components'; -import { CSSVariable } from '@/global_style'; -import { CSS_VAR } from '@/components/theme'; -import getResizedImage from '@/server/asset/get_resized_image'; -import { Music } from '../constants'; -import MusicInfo from '../components/music_info'; -import { FLOATING_GAP } from './constants'; - -const PRIMARY = `var(${CSS_VAR.colorPrimary})`; -const NEUTRAL_SHADOW = CSSVariable.COLOR_NEUTRAL_SHADOW; - -const Style = styled.div` - z-index: 1; - - position: sticky; - top: 0; - - display: flex; - flex-direction: column; - gap: 12px; - pointer-events: none; - - > .music-info { - /* 比下方乐单条目更窄, 悬浮在上方时通过左右缩进与乐单区分, 避免视觉融合 */ - margin: 0 24px; - - pointer-events: auto; - background: #fff; - border: 2px solid ${CSSVariable.COLOR_BORDER}; - border-radius: 14px; - box-shadow: 0 4px 0 ${NEUTRAL_SHADOW}; - transition: - transform 150ms ease-out, - box-shadow 150ms ease-out, - border-color 150ms ease-out, - filter 120ms ease-out; - - > :first-child { - flex: 0 0 auto; - box-sizing: border-box; - overflow: hidden; - - background: #fff; - border: 2px solid ${CSSVariable.COLOR_BORDER}; - border-radius: 10px; - box-shadow: 0 3px 0 ${NEUTRAL_SHADOW}; - } - - > .info { - display: flex; - flex-direction: column; - gap: 3px; - } - - > .info > .name { - font-family: 'Nunito', 'Varela Round', system-ui, sans-serif; - font-size: 15px; - font-weight: 900; - letter-spacing: 0; - line-height: 1.2; - color: rgb(50 50 50); - } - - > .info > .performers { - font-family: 'Nunito', 'Varela Round', system-ui, sans-serif; - font-size: 12px; - font-weight: 700; - letter-spacing: 0; - line-height: 1.25; - color: ${CSSVariable.TEXT_COLOR_SECONDARY}; - - .name { - font-weight: 800; - } - } - - &:hover { - background: #fff; - border-color: ${PRIMARY}; - } - - &:active { - transform: translateY(4px); - box-shadow: none; - transition: - transform 60ms ease-in, - box-shadow 60ms ease-in; - } - } -`; - -function Top({ music }: { music: Music }) { - return ( - - ); -} - -export default Top; diff --git a/apps/pwa/src/pages/player/pages/artist/index.tsx b/apps/pwa/src/pages/player/pages/artist/index.tsx index e62995534..02b65ef18 100644 --- a/apps/pwa/src/pages/player/pages/artist/index.tsx +++ b/apps/pwa/src/pages/player/pages/artist/index.tsx @@ -1,7 +1,7 @@ import { useParams } from 'react-router-dom'; import styled from 'styled-components'; import Page from '../page'; -import ArtistContent from '../../artist_drawer/content'; +import ArtistContent from '@/features/player/drawers/artist_drawer/content'; const Style = styled(Page)` z-index: 1; @@ -10,7 +10,6 @@ const Style = styled(Page)` left: 0; width: 100%; height: 100%; - background: #fff; `; function Wrapper() { diff --git a/apps/pwa/src/pages/player/pages/exploration/cover.tsx b/apps/pwa/src/pages/player/pages/exploration/cover.tsx index 398752f5e..4fe3d42b5 100644 --- a/apps/pwa/src/pages/player/pages/exploration/cover.tsx +++ b/apps/pwa/src/pages/player/pages/exploration/cover.tsx @@ -11,6 +11,7 @@ const Style = styled.div<{ $variant: Variant; }>` position: relative; + isolation: isolate; min-width: 0; padding: ${({ $variant }) => { if ($variant === 'profile') { @@ -46,7 +47,14 @@ const Style = styled.div<{ border-radius: 50%; background: ${({ $accent }) => $accent}; box-shadow: 0 2px 0 ${({ $shadow }) => $shadow}; - z-index: 2; + z-index: 4; + pointer-events: none; + transform: translateZ(0); + } + + > * { + position: relative; + z-index: 1; } > .info { @@ -191,6 +199,7 @@ const CassetteScene = styled.div<{ }>` position: relative; aspect-ratio: 1; + isolation: isolate; overflow: hidden; border: 3px solid ${({ $shadow }) => $shadow}; @@ -201,16 +210,17 @@ const CassetteScene = styled.div<{ content: ''; position: absolute; inset: 0; - z-index: 1; - background: - linear-gradient(180deg, transparent 0 48%, rgb(0 0 0 / 0.08) 100%), - radial-gradient(circle at 20% 16%, rgb(255 255 255 / 0.42) 0 14px, transparent 15px); + z-index: 2; + background: linear-gradient(180deg, transparent 0 48%, rgb(0 0 0 / 0.08) 100%); pointer-events: none; + transform: translateZ(0); } > .artwork-frame { position: absolute; inset: 0; + z-index: 0; + contain: paint; overflow: hidden; } @@ -228,7 +238,7 @@ const CassetteScene = styled.div<{ left: 7%; right: 7%; bottom: 7%; - z-index: 2; + z-index: 3; height: 38%; padding: 7% 12% 6%; diff --git a/apps/pwa/src/pages/player/pages/exploration/index.tsx b/apps/pwa/src/pages/player/pages/exploration/index.tsx index b40134c98..6a77f8462 100644 --- a/apps/pwa/src/pages/player/pages/exploration/index.tsx +++ b/apps/pwa/src/pages/player/pages/exploration/index.tsx @@ -59,9 +59,6 @@ type ExplorationMode = 'recommendation' | 'search'; const Root = styled(Page)` position: relative; overflow: hidden; - background: - linear-gradient(180deg, rgb(247 253 248) 0, rgb(248 249 250) 310px), - rgb(248 249 250); > .search-toolbar { z-index: 2; @@ -452,6 +449,7 @@ function RecommendationPanel() { url: publicMusicbill.cover, size: imageSize, })} + placeholderSrc={publicMusicbill.coverThumbnail} onClick={() => openMusicbillDrawer(publicMusicbill.id)} info={} /> diff --git a/apps/pwa/src/pages/player/pages/music/index.tsx b/apps/pwa/src/pages/player/pages/music/index.tsx index df793792f..9a5539e51 100644 --- a/apps/pwa/src/pages/player/pages/music/index.tsx +++ b/apps/pwa/src/pages/player/pages/music/index.tsx @@ -1,7 +1,7 @@ import { useParams } from 'react-router-dom'; import styled from 'styled-components'; import Page from '../page'; -import MusicContent from '../../music_drawer/content'; +import MusicContent from '@/features/player/drawers/music_drawer/content'; const Style = styled(Page)` z-index: 1; @@ -10,7 +10,6 @@ const Style = styled(Page)` left: 0; width: 100%; height: 100%; - background: #fff; `; function Wrapper() { diff --git a/apps/pwa/src/pages/player/pages/musicbill/edit_menu.tsx b/apps/pwa/src/pages/player/pages/musicbill/edit_menu.tsx index 523d265bf..ac9b04655 100644 --- a/apps/pwa/src/pages/player/pages/musicbill/edit_menu.tsx +++ b/apps/pwa/src/pages/player/pages/musicbill/edit_menu.tsx @@ -350,6 +350,7 @@ function EditMenu({ musicbill }: { musicbill: Musicbill }) { url: musicbill.cover, size: COVER_SIZE * 2, })} + placeholderSrc={musicbill.coverThumbnail} size={COVER_SIZE} /> diff --git a/apps/pwa/src/pages/player/pages/musicbill/info.tsx b/apps/pwa/src/pages/player/pages/musicbill/info.tsx index 7e2544162..8ec9e8d53 100644 --- a/apps/pwa/src/pages/player/pages/musicbill/info.tsx +++ b/apps/pwa/src/pages/player/pages/musicbill/info.tsx @@ -118,6 +118,7 @@ function Info({ musicbill }: { musicbill: Musicbill }) { .scrollable { height: 100%; overflow: auto; padding: ${PAGE_HORIZONTAL_PADDING}; - background: - linear-gradient(180deg, rgb(247 253 248) 0, rgb(248 249 250) 300px), - rgb(248 249 250); ${autoScrollbar} } `; diff --git a/apps/pwa/src/pages/player/pages/musicbill/operation.tsx b/apps/pwa/src/pages/player/pages/musicbill/operation.tsx index 38c2a6862..ac9059293 100644 --- a/apps/pwa/src/pages/player/pages/musicbill/operation.tsx +++ b/apps/pwa/src/pages/player/pages/musicbill/operation.tsx @@ -33,7 +33,7 @@ function Operation({ musicbill }: { musicbill: Musicbill }) { const shared = sharedUserList.length > 0; return ( diff --git a/apps/pwa/src/pages/player/sidebar/musicbill_list/top.tsx b/apps/pwa/src/pages/player/sidebar/musicbill_list/top.tsx index edf1103e1..07debfaea 100644 --- a/apps/pwa/src/pages/player/sidebar/musicbill_list/top.tsx +++ b/apps/pwa/src/pages/player/sidebar/musicbill_list/top.tsx @@ -60,7 +60,7 @@ function Top() { return ( - ); -} - -export default MiniInfo; diff --git a/apps/pwa/src/pages/player/user_drawer/musicbill_list.tsx b/apps/pwa/src/pages/player/user_drawer/musicbill_list.tsx deleted file mode 100644 index 33d2bbfb4..000000000 --- a/apps/pwa/src/pages/player/user_drawer/musicbill_list.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/features/player/drawers/user_drawer/musicbill_list'; diff --git a/apps/pwa/src/pages/player/user_drawer/use_data.ts b/apps/pwa/src/pages/player/user_drawer/use_data.ts deleted file mode 100644 index 8d383d4d7..000000000 --- a/apps/pwa/src/pages/player/user_drawer/use_data.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/features/player/drawers/user_drawer/use_data'; diff --git a/apps/pwa/src/pages/player/user_drawer/user_drawer.tsx b/apps/pwa/src/pages/player/user_drawer/user_drawer.tsx deleted file mode 100644 index b10fc8597..000000000 --- a/apps/pwa/src/pages/player/user_drawer/user_drawer.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/features/player/drawers/user_drawer/user_drawer'; diff --git a/apps/pwa/src/pages/radio/page.tsx b/apps/pwa/src/pages/radio/page.tsx index c9b9b3b11..2c0e55bc8 100644 --- a/apps/pwa/src/pages/radio/page.tsx +++ b/apps/pwa/src/pages/radio/page.tsx @@ -19,7 +19,6 @@ const Style = styled.div` position: relative; width: 100%; height: 100%; - background-color: #fff; overflow: hidden; `; const Bottom = styled.div` diff --git a/apps/pwa/src/server/api/admin_get_music.ts b/apps/pwa/src/server/api/admin_get_music.ts index 407f1f5a5..1abe80bdf 100644 --- a/apps/pwa/src/server/api/admin_get_music.ts +++ b/apps/pwa/src/server/api/admin_get_music.ts @@ -45,6 +45,7 @@ type Response = Omit & { id: string; name: string; cover: string; + coverThumbnail?: string; musicCount: number; user: { id: string; @@ -147,6 +148,7 @@ async function adminGetMusic({ ).map((musicbill) => ({ ...musicbill, cover: prefixServerOrigin(musicbill.cover), + coverThumbnail: prefixServerOrigin(musicbill.coverThumbnail ?? ''), user: { ...musicbill.user, avatar: prefixServerOrigin(musicbill.user.avatar), diff --git a/apps/pwa/src/server/api/get_exploration.ts b/apps/pwa/src/server/api/get_exploration.ts index d362420b9..67e5babaa 100644 --- a/apps/pwa/src/server/api/get_exploration.ts +++ b/apps/pwa/src/server/api/get_exploration.ts @@ -20,6 +20,7 @@ type PublicMusicbillItem = { id: string; name: string; cover: string; + coverThumbnail?: string; user: { id: string; nickname: string }; }; type Response = { @@ -47,6 +48,7 @@ const normalizeArtist = (artist: ArtistItem) => ({ const normalizeMusicbill = (mb: PublicMusicbillItem) => ({ ...mb, cover: prefixServerOrigin(mb.cover), + coverThumbnail: prefixServerOrigin(mb.coverThumbnail ?? ''), }); /** diff --git a/apps/pwa/src/server/api/get_music.ts b/apps/pwa/src/server/api/get_music.ts index 40f87a7c2..83f708527 100644 --- a/apps/pwa/src/server/api/get_music.ts +++ b/apps/pwa/src/server/api/get_music.ts @@ -45,6 +45,7 @@ type Response = Omit & { id: string; name: string; cover: string; + coverThumbnail?: string; musicCount: number; user: { id: string; @@ -185,6 +186,7 @@ async function getMusic({ ).map((mb) => ({ ...mb, cover: prefixServerOrigin(mb.cover), + coverThumbnail: prefixServerOrigin(mb.coverThumbnail ?? ''), user: { ...mb.user, avatar: prefixServerOrigin(mb.user.avatar), diff --git a/apps/pwa/src/server/api/get_musicbill.ts b/apps/pwa/src/server/api/get_musicbill.ts index 650d3ecb8..7266af694 100644 --- a/apps/pwa/src/server/api/get_musicbill.ts +++ b/apps/pwa/src/server/api/get_musicbill.ts @@ -11,6 +11,7 @@ interface User { interface Response { id: string; cover: string; + coverThumbnail?: string; name: string; public: boolean; createTimestamp: number; @@ -60,6 +61,7 @@ async function getMusicbill(id: string) { return { ...musicbill, cover: prefixServerOrigin(musicbill.cover), + coverThumbnail: prefixServerOrigin(musicbill.coverThumbnail ?? ''), owner: { ...musicbill.owner, avatar: prefixServerOrigin(musicbill.owner.avatar), diff --git a/apps/pwa/src/server/api/get_musicbill_list.ts b/apps/pwa/src/server/api/get_musicbill_list.ts index 2be8d926a..eac0d4f04 100644 --- a/apps/pwa/src/server/api/get_musicbill_list.ts +++ b/apps/pwa/src/server/api/get_musicbill_list.ts @@ -10,6 +10,7 @@ interface User { type Response = { id: string; cover: string; + coverThumbnail?: string; name: string; public: boolean; createTimestamp: number; @@ -27,6 +28,7 @@ async function getMusicbillList() { return musicbillList.map((mb) => ({ ...mb, cover: prefixServerOrigin(mb.cover), + coverThumbnail: prefixServerOrigin(mb.coverThumbnail ?? ''), owner: { ...mb.owner, avatar: prefixServerOrigin(mb.owner.avatar), diff --git a/apps/pwa/src/server/api/get_public_musicbill.ts b/apps/pwa/src/server/api/get_public_musicbill.ts index a18368f1e..b31e0993e 100644 --- a/apps/pwa/src/server/api/get_public_musicbill.ts +++ b/apps/pwa/src/server/api/get_public_musicbill.ts @@ -5,6 +5,7 @@ import { request } from '..'; type Response = { id: string; cover: string; + coverThumbnail?: string; name: string; public: boolean; user: { @@ -53,6 +54,7 @@ async function getPublicMusicbill(id: string) { return { ...musicbill, cover: prefixServerOrigin(musicbill.cover), + coverThumbnail: prefixServerOrigin(musicbill.coverThumbnail ?? ''), musicList: musicbill.musicList.map((m) => ({ ...m, cover: prefixServerOrigin(m.cover), diff --git a/apps/pwa/src/server/api/get_public_musicbill_collection_list.ts b/apps/pwa/src/server/api/get_public_musicbill_collection_list.ts index 2b654e85b..21103a82d 100644 --- a/apps/pwa/src/server/api/get_public_musicbill_collection_list.ts +++ b/apps/pwa/src/server/api/get_public_musicbill_collection_list.ts @@ -7,6 +7,7 @@ type Response = { id: string; name: string; cover: string; + coverThumbnail?: string; musicCount: number; user: { id: string; nickname: string }; }[]; @@ -39,6 +40,7 @@ async function getSelfMusicbillCollectionList({ ...mb, musicCount: mb.musicCount ?? 0, cover: prefixServerOrigin(mb.cover), + coverThumbnail: prefixServerOrigin(mb.coverThumbnail ?? ''), })), }; } diff --git a/apps/pwa/src/server/api/get_user.ts b/apps/pwa/src/server/api/get_user.ts index 4949f6d0b..1bc732145 100644 --- a/apps/pwa/src/server/api/get_user.ts +++ b/apps/pwa/src/server/api/get_user.ts @@ -10,6 +10,7 @@ interface Response { musicbillList: { id: string; cover: string; + coverThumbnail?: string; name: string; musicCount: number; }[]; @@ -31,6 +32,7 @@ async function getUser(id: string) { musicbillList: user.musicbillList.map((mb) => ({ ...mb, cover: prefixServerOrigin(mb.cover), + coverThumbnail: prefixServerOrigin(mb.coverThumbnail ?? ''), })), }; } diff --git a/apps/pwa/src/server/api/search_public_musicbill.ts b/apps/pwa/src/server/api/search_public_musicbill.ts index c7ebe1901..dcf8bc864 100644 --- a/apps/pwa/src/server/api/search_public_musicbill.ts +++ b/apps/pwa/src/server/api/search_public_musicbill.ts @@ -7,6 +7,7 @@ type Response = { id: string; name: string; cover: string; + coverThumbnail?: string; musicCount: number; collectionCount: number; user: { @@ -49,6 +50,7 @@ async function searchPublicMusicbill({ musicCount: mb.musicCount ?? 0, collectionCount: mb.collectionCount ?? 0, cover: prefixServerOrigin(mb.cover), + coverThumbnail: prefixServerOrigin(mb.coverThumbnail ?? ''), user: { ...mb.user, avatar: prefixServerOrigin(mb.user.avatar), diff --git a/apps/pwa/src/style/hover.ts b/apps/pwa/src/style/hover.ts new file mode 100644 index 000000000..8ea9d1022 --- /dev/null +++ b/apps/pwa/src/style/hover.ts @@ -0,0 +1,36 @@ +import { + css, + DefaultTheme, + FlattenInterpolation, + ThemeProps, +} from 'styled-components'; + +/** + * 精确指针且支持真实悬停的设备 (鼠标等), 用于区隔触摸设备. + * 作为「悬停态」与「仅触摸设备才需要的交互件」共用的判定条件. + */ +export const FINE_POINTER_MEDIA = '(hover: hover) and (pointer: fine)'; + +/** + * 仅在支持真实悬停的设备 (鼠标等精确指针) 上应用样式, + * 规避触摸设备点击后 hover 状态粘滞的问题. + * + * 选择器由调用方书写, 以兼容后代/嵌套等复杂形态; 若内部样式依赖 + * styled 组件的 props, 通过 `css` 传入以保留类型推断: + * ```ts + * ${hover(css` + * &:not(:disabled):hover { + * filter: brightness(1.04); + * } + * `)} + * ``` + */ +const hover =

>( + style: FlattenInterpolation & P>, +) => css` + @media ${FINE_POINTER_MEDIA} { + ${style} + } +`; + +export default hover; diff --git a/apps/pwa/src/style/tokens_style.ts b/apps/pwa/src/style/tokens_style.ts index f87490699..4ba7d422a 100644 --- a/apps/pwa/src/style/tokens_style.ts +++ b/apps/pwa/src/style/tokens_style.ts @@ -68,25 +68,24 @@ const CSS_VARIABLE_MAP_VALUE: Record = { [CSSVariable.BORDER_RADIUS_NORMAL]: '4px', }; -const DUOLINGO_FONT_FAMILY = +const FONT_FAMILY = "'Nunito', 'Varela Round', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; export const TokensStyle = createGlobalStyle` html { ${Object.keys(CSS_VARIABLE_MAP_VALUE) - .map( - (variable) => - `${variable.match(/^var\((.+)\)$/)![1]}: ${ - CSS_VARIABLE_MAP_VALUE[variable] - };`, - ) - .join('\n')} + .map( + (variable) => + `${variable.match(/^var\((.+)\)$/)![1]}: ${CSS_VARIABLE_MAP_VALUE[variable] + };`, + ) + .join('\n')} accent-color: ${CSSVariable.COLOR_PRIMARY}; } body { - font-family: ${DUOLINGO_FONT_FAMILY}; + font-family: ${FONT_FAMILY}; } input, diff --git a/apps/pwa/src/utils/sleep.ts b/apps/pwa/src/utils/sleep.ts index b6d257bb4..9f082db5b 100644 --- a/apps/pwa/src/utils/sleep.ts +++ b/apps/pwa/src/utils/sleep.ts @@ -3,7 +3,9 @@ * @author mebtte */ function sleep(ms: number) { - return new Promise((resolve) => global.setTimeout(() => resolve(), ms)); + return new Promise((resolve) => + globalThis.setTimeout(() => resolve(), ms), + ); } export default sleep; diff --git a/apps/pwa/src/utils/timeout.ts b/apps/pwa/src/utils/timeout.ts index a237ae87c..7d18e81a7 100644 --- a/apps/pwa/src/utils/timeout.ts +++ b/apps/pwa/src/utils/timeout.ts @@ -1,6 +1,6 @@ function timeout(ms: number) { return new Promise((_resolve, reject) => - global.setTimeout(() => reject(new Error(`Timeout of ${ms}ms.`)), ms), + globalThis.setTimeout(() => reject(new Error(`Timeout of ${ms}ms.`)), ms), ); } diff --git a/apps/pwa/tsconfig.json b/apps/pwa/tsconfig.json index 6d5e9b53b..8fd175742 100644 --- a/apps/pwa/tsconfig.json +++ b/apps/pwa/tsconfig.json @@ -1,19 +1,20 @@ { "compilerOptions": { "target": "ESNext", + "strict": false, "strictNullChecks": true, "skipLibCheck": true, - "moduleResolution": "Node", + "moduleResolution": "Bundler", "allowSyntheticDefaultImports": true, "esModuleInterop": true, "resolveJsonModule": true, "module": "ESNext", "jsx": "react-jsx", "lib": ["DOM", "WebWorker", "ESNext", "DOM.Iterable"], + "types": ["node"], - "baseUrl": ".", "paths": { - "@/*": ["src/*"] + "@/*": ["./src/*"] } }, "include": ["src/**/*"], diff --git a/docs/ui_designment/index.md b/docs/ui_designment/index.md index 50c64f05f..d543d5868 100644 --- a/docs/ui_designment/index.md +++ b/docs/ui_designment/index.md @@ -1,6 +1,6 @@ # UI Designment -`cicada` prefers comic styles and currently mostly likes `duolingo`. +`cicada` prefers comic styles. ## Rules diff --git a/readme.md b/readme.md index cf91e0b03..1c72a7c88 100644 --- a/readme.md +++ b/readme.md @@ -39,10 +39,6 @@ On Windows, use `cicada.exe`: On the first startup, Cicada creates a default user and prints `username`/`password` to the log. After startup, you can visit cicada on `http://localhost:8000`. -## Development - -If you are interested in developing `cicada`, see the development [docs](./docs/development/index.md). - ## License [GPL](./license)