diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08cc3b1..1c315d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ permissions: jobs: build: name: Build - runs-on: macos-latest + runs-on: macos-26 timeout-minutes: 30 steps: @@ -48,9 +48,11 @@ jobs: -scheme perch \ -configuration Debug \ -destination 'platform=macOS' \ + -skipPackagePluginValidation \ + -skipMacroValidation \ CODE_SIGNING_ALLOWED=NO \ build \ - 2>&1 | xcbeautify --renderer github-actions + 2>&1 | xcbeautify lint: name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a596a7..d7d8932 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,13 +5,18 @@ on: tags: - 'v*' workflow_dispatch: + inputs: + version: + description: 'Release tag (e.g. v0.3.0-beta-1)' + required: true + type: string permissions: contents: write jobs: release: - runs-on: macos-15 + runs-on: macos-26 env: SCHEME: perch APP_NAME: perch @@ -22,10 +27,25 @@ jobs: - name: Resolve version id: version run: | - TAG="${GITHUB_REF_NAME:-v0.2.0}" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="${{ github.event.inputs.version }}" + else + TAG="${GITHUB_REF_NAME}" + fi VERSION="${TAG#v}" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" + BASE_VERSION="${VERSION%%-*}" + if [[ "$VERSION" =~ -[a-zA-Z] ]]; then + IS_BETA=true + BUNDLE_NAME="perch-beta" + else + IS_BETA=false + BUNDLE_NAME="perch" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "base_version=$BASE_VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "is_beta=$IS_BETA" >> "$GITHUB_OUTPUT" + echo "bundle_name=$BUNDLE_NAME" >> "$GITHUB_OUTPUT" - name: Build arm64 archive run: | @@ -35,27 +55,15 @@ jobs: -configuration Release \ -archivePath build/$APP_NAME-arm64.xcarchive \ -destination 'generic/platform=macOS' \ + -skipPackagePluginValidation \ + -skipMacroValidation \ ARCHS=arm64 \ ONLY_ACTIVE_ARCH=NO \ CODE_SIGN_IDENTITY="-" \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ - SKIP_INSTALL=NO 2>&1 | tail -5 - - - name: Build x86_64 archive - run: | - set -o pipefail - xcodebuild archive \ - -scheme "$SCHEME" \ - -configuration Release \ - -archivePath build/$APP_NAME-x86_64.xcarchive \ - -destination 'generic/platform=macOS' \ - ARCHS=x86_64 \ - ONLY_ACTIVE_ARCH=NO \ - CODE_SIGN_IDENTITY="-" \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO \ - SKIP_INSTALL=NO 2>&1 | tail -5 + MARKETING_VERSION="${{ steps.version.outputs.base_version }}" \ + SKIP_INSTALL=NO 2>&1 | tail -100 - name: Build universal archive run: | @@ -65,28 +73,52 @@ jobs: -configuration Release \ -archivePath build/$APP_NAME-universal.xcarchive \ -destination 'generic/platform=macOS' \ + -skipPackagePluginValidation \ + -skipMacroValidation \ "ARCHS=arm64 x86_64" \ ONLY_ACTIVE_ARCH=NO \ CODE_SIGN_IDENTITY="-" \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ - SKIP_INSTALL=NO 2>&1 | tail -5 + MARKETING_VERSION="${{ steps.version.outputs.base_version }}" \ + SKIP_INSTALL=NO 2>&1 | tail -100 + + - name: Create x86_64 archive from universal (lipo thin) + run: | + cp -Rp "build/$APP_NAME-universal.xcarchive" "build/$APP_NAME-x86_64.xcarchive" + find "build/$APP_NAME-x86_64.xcarchive/Products" -type f | while read f; do + if file "$f" | grep -q "Mach-O universal binary"; then + lipo "$f" -thin x86_64 -output "$f" + fi + done + echo "Created x86_64 archive from universal via lipo" + + - name: Rename app bundle for beta + if: steps.version.outputs.is_beta == 'true' + run: | + for ARCH in arm64 x86_64 universal; do + SRC="build/$APP_NAME-${ARCH}.xcarchive/Products/Applications/perch.app" + DST="build/$APP_NAME-${ARCH}.xcarchive/Products/Applications/perch-beta.app" + mv "$SRC" "$DST" + echo "Renamed: perch.app → perch-beta.app (${ARCH})" + done - name: Create DMGs run: | VERSION="${{ steps.version.outputs.version }}" + BUNDLE_NAME="${{ steps.version.outputs.bundle_name }}" mkdir -p build/dmg for ARCH in arm64 x86_64 universal; do - APP="build/$APP_NAME-${ARCH}.xcarchive/Products/Applications/$APP_NAME.app" + APP="build/$APP_NAME-${ARCH}.xcarchive/Products/Applications/${BUNDLE_NAME}.app" STAGING="build/dmg-staging-${ARCH}" rm -rf "$STAGING" mkdir -p "$STAGING" - cp -R "$APP" "$STAGING/$APP_NAME.app" + cp -R "$APP" "$STAGING/${BUNDLE_NAME}.app" - codesign --force --deep --sign - --timestamp=none "$STAGING/$APP_NAME.app" - codesign --verify --deep --strict --verbose=4 "$STAGING/$APP_NAME.app" - if ! spctl --assess --type execute --verbose=4 "$STAGING/$APP_NAME.app"; then + codesign --force --deep --sign - --timestamp=none "$STAGING/${BUNDLE_NAME}.app" + codesign --verify --deep --strict --verbose=4 "$STAGING/${BUNDLE_NAME}.app" + if ! spctl --assess --type execute --verbose=4 "$STAGING/${BUNDLE_NAME}.app"; then echo "::warning::spctl rejected ad-hoc signed app for ${ARCH}; continuing." fi @@ -99,28 +131,47 @@ jobs: echo "Created: $APP_NAME-${VERSION}-${ARCH}.dmg" done + - name: Compose release body + id: body + run: | + VERSION="${{ steps.version.outputs.version }}" + IS_BETA="${{ steps.version.outputs.is_beta }}" + BUNDLE_NAME="${{ steps.version.outputs.bundle_name }}" + + if [ "$IS_BETA" = "true" ]; then + LABEL=" (beta)" + else + LABEL="" + fi + + { + echo "body<<__BODY__" + echo "## Perch ${VERSION}${LABEL}" + echo "" + echo "### Install via Homebrew" + echo '```bash' + echo "brew tap tukuyomil032/tap" + echo "brew install --cask ${BUNDLE_NAME}" + echo '```' + echo "" + echo "### Direct Download" + echo "- **universal.dmg** (recommended) — Apple Silicon + Intel" + echo "- **arm64.dmg** — Apple Silicon only" + echo "- **x86_64.dmg** — Intel only" + echo "" + echo "> ⚠️ Not notarized by Apple. If Gatekeeper blocks it: right-click > Open > Open." + echo "__BODY__" + } >> "$GITHUB_OUTPUT" + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: + tag_name: ${{ steps.version.outputs.tag }} name: "Perch ${{ steps.version.outputs.version }}" - body: | - ## Perch ${{ steps.version.outputs.version }} (beta) - - ### Install via Homebrew - ```bash - brew tap tukuyomil032/tap - brew install --cask perch - ``` - - ### Direct Download - - **universal.dmg** (recommended) — Apple Silicon + Intel - - **arm64.dmg** — Apple Silicon only - - **x86_64.dmg** — Intel only - - > ⚠️ Not notarized by Apple. If Gatekeeper blocks it: right-click > Open > Open. + body: ${{ steps.body.outputs.body }} files: | build/dmg/${{ env.APP_NAME }}-${{ steps.version.outputs.version }}-arm64.dmg build/dmg/${{ env.APP_NAME }}-${{ steps.version.outputs.version }}-x86_64.dmg build/dmg/${{ env.APP_NAME }}-${{ steps.version.outputs.version }}-universal.dmg draft: false - prerelease: false + prerelease: ${{ steps.version.outputs.is_beta == 'true' }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e88c241..8e378f9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ permissions: jobs: test: name: Unit Tests - runs-on: macos-latest + runs-on: macos-26 timeout-minutes: 30 steps: @@ -49,6 +49,8 @@ jobs: -configuration Debug \ -destination 'platform=macOS' \ -skip-testing:perchUITests \ + -skipPackagePluginValidation \ + -skipMacroValidation \ CODE_SIGNING_ALLOWED=NO \ test \ - 2>&1 | xcbeautify --renderer github-actions + 2>&1 | xcbeautify diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..338719f --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,8 @@ +# Third-Party Notices + +## LyricsKit + +- Source: https://github.com/MxIris-LyricsX-Project/LyricsKit +- License: Mozilla Public License 2.0 (MPL-2.0) +- Copyright: ddddxxx and LyricsX project contributors +- License text: https://www.mozilla.org/en-US/MPL/2.0/ diff --git a/perch.xcodeproj/project.pbxproj b/perch.xcodeproj/project.pbxproj index 83de3e9..5a5a852 100644 --- a/perch.xcodeproj/project.pbxproj +++ b/perch.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ BA0A72422FCEBA4F00F08C06 /* InMemoryLogging in Frameworks */ = {isa = PBXBuildFile; productRef = BA0A72412FCEBA4F00F08C06 /* InMemoryLogging */; }; BA0A72442FCEBA4F00F08C06 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = BA0A72432FCEBA4F00F08C06 /* Logging */; }; BA0A72472FCEBA7600F08C06 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = BA0A72462FCEBA7600F08C06 /* KeyboardShortcuts */; }; + BA0A724A2FCEBABC00F08C06 /* LyricsKit in Frameworks */ = {isa = PBXBuildFile; productRef = BA0A72492FCEBABC00F08C06 /* LyricsKit */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -62,6 +63,7 @@ BA0A723D2FCEBA1A00F08C06 /* Defaults in Frameworks */, BA0A72422FCEBA4F00F08C06 /* InMemoryLogging in Frameworks */, BA0A72472FCEBA7600F08C06 /* KeyboardShortcuts in Frameworks */, + BA0A724A2FCEBABC00F08C06 /* LyricsKit in Frameworks */, BA0A72442FCEBA4F00F08C06 /* Logging in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -127,6 +129,7 @@ BA0A72412FCEBA4F00F08C06 /* InMemoryLogging */, BA0A72432FCEBA4F00F08C06 /* Logging */, BA0A72462FCEBA7600F08C06 /* KeyboardShortcuts */, + BA0A72492FCEBABC00F08C06 /* LyricsKit */, ); productName = perch; productReference = BA0A707E2FCD694B00F08C06 /* perch.app */; @@ -215,6 +218,7 @@ BA0A723B2FCEBA1A00F08C06 /* XCRemoteSwiftPackageReference "Defaults" */, BA0A72402FCEBA4F00F08C06 /* XCRemoteSwiftPackageReference "swift-log" */, BA0A72452FCEBA7600F08C06 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */, + BA0A72482FCEBABC00F08C06 /* XCRemoteSwiftPackageReference "LyricsKit" */, ); preferredProjectObjectVersion = 77; productRefGroup = BA0A707F2FCD694B00F08C06 /* Products */; @@ -429,7 +433,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.2.0; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.tukuyomi032.perch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -460,7 +464,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.2.0; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.tukuyomi032.perch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -481,7 +485,7 @@ DEVELOPMENT_TEAM = Q9CTDZWWR9; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.2.0; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.tukuyomi032.perchTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -502,7 +506,7 @@ DEVELOPMENT_TEAM = Q9CTDZWWR9; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.2.0; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.tukuyomi032.perchTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -521,7 +525,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = Q9CTDZWWR9; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.2.0; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.tukuyomi032.perchUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -540,7 +544,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = Q9CTDZWWR9; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.2.0; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.tukuyomi032.perchUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -618,6 +622,14 @@ minimumVersion = 2.4.0; }; }; + BA0A72482FCEBABC00F08C06 /* XCRemoteSwiftPackageReference "LyricsKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MxIris-LyricsX-Project/LyricsKit"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.9.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -641,6 +653,11 @@ package = BA0A72452FCEBA7600F08C06 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */; productName = KeyboardShortcuts; }; + BA0A72492FCEBABC00F08C06 /* LyricsKit */ = { + isa = XCSwiftPackageProductDependency; + package = BA0A72482FCEBABC00F08C06 /* XCRemoteSwiftPackageReference "LyricsKit" */; + productName = LyricsKit; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = BA0A70762FCD694B00F08C06 /* Project object */; diff --git a/perch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/perch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 62b6ef0..4a8f82c 100644 --- a/perch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/perch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,24 @@ { - "originHash" : "9b119c3e8d1af759685a5c11b25453b4daa6ff2d08b2a384ae63b84b616a0232", + "originHash" : "317d68ae871be5e6fb9d9da880fd72139c5507dfce692f0d806c63d5069bede1", "pins" : [ + { + "identity" : "bigint", + "kind" : "remoteSourceControl", + "location" : "https://github.com/attaswift/BigInt", + "state" : { + "revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe", + "version" : "5.7.0" + } + }, + { + "identity" : "cryptoswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzyzanowskim/CryptoSwift", + "state" : { + "revision" : "f2a627b84c1ff96f21ac2fcb623ab36142dd5512", + "version" : "1.10.0" + } + }, { "identity" : "defaults", "kind" : "remoteSourceControl", @@ -10,6 +28,15 @@ "version" : "9.0.6" } }, + { + "identity" : "frameworktoolbox", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Mx-Iris/FrameworkToolbox", + "state" : { + "revision" : "b82281eb8a6ffcb312941c3d06584182837f4ca9", + "version" : "0.7.1" + } + }, { "identity" : "keyboardshortcuts", "kind" : "remoteSourceControl", @@ -19,6 +46,42 @@ "version" : "2.4.0" } }, + { + "identity" : "lyricskit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-LyricsX-Project/LyricsKit", + "state" : { + "revision" : "e739415b0a2e978b474112f352b3fcd24bb6deb4", + "version" : "1.9.0" + } + }, + { + "identity" : "regex", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ddddxxx/Regex", + "state" : { + "revision" : "c0ad0a7e9a48989d9688c2b524a1e69b6293b733", + "version" : "1.0.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "revision" : "d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440", + "version" : "1.1.4" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", + "version" : "1.5.1" + } + }, { "identity" : "swift-log", "kind" : "remoteSourceControl", @@ -36,6 +99,15 @@ "revision" : "4799286537280063c85a32f09884cfbca301b1a1", "version" : "602.0.0" } + }, + { + "identity" : "swiftcf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Library-Forks/SwiftCF", + "state" : { + "revision" : "46f9d814b5197d33286235036d2e3c982e885fad", + "version" : "0.2.2" + } } ], "version" : 3 diff --git a/perch/Features/NowPlaying/LyricsKitFetcher.swift b/perch/Features/NowPlaying/LyricsKitFetcher.swift new file mode 100644 index 0000000..095d07a --- /dev/null +++ b/perch/Features/NowPlaying/LyricsKitFetcher.swift @@ -0,0 +1,78 @@ +// perch/Features/NowPlaying/LyricsKitFetcher.swift +import Foundation +import Logging +import LyricsKit + +actor LyricsKitFetcher { + static let shared = LyricsKitFetcher() + private let logger = Logger(label: "com.tukuyomi032.perch.LyricsKitFetcher") + + func fetch(title: String, artist: String) async -> [LyricsLine]? { + // duration: 0 = no duration filter; match quality is slightly lower but still usable. + // Future: pass NowPlayingState.duration here for better accuracy. + let request = LyricsSearchRequest( + searchTerm: .info(title: title, artist: artist), + duration: 0 + ) + let providers: [any LyricsProvider] = [ + LyricsProviders.Service.netease.create(), + LyricsProviders.Service.qq.create(), + LyricsProviders.Service.kugou.create(), + ] + for provider in providers { + if let lines = await fetchFromProvider(provider, request: request, expectedTitle: title) { + return lines + } + } + return nil + } + + private func fetchFromProvider( + _ provider: any LyricsProvider, + request: LyricsSearchRequest, + expectedTitle: String + ) async -> [LyricsLine]? { + do { + for try await kitLyrics in provider.lyrics(for: request) { + if let returnedTitle = kitLyrics.idTags[.title], !returnedTitle.isEmpty { + let norm1 = returnedTitle.lowercased() + let norm2 = expectedTitle.lowercased() + guard norm1.contains(norm2) || norm2.contains(norm1) else { + logger.debug( + "LyricsKitFetcher: skipping mismatched title '\(returnedTitle)' (expected '\(expectedTitle)')" + ) + continue + } + } + let lines = kitLyrics.lines + .filter { $0.enabled && !$0.content.isEmpty } + .map { LyricsLine(timestamp: $0.position, text: $0.content) } + if !lines.isEmpty { + if titleIsJapanese(expectedTitle) && !lyricsContainJapanese(lines) { + logger.debug("LyricsKitFetcher: skipping non-Japanese lyrics for '\(expectedTitle)'") + continue + } + logger.debug("LyricsKitFetcher: found \(lines.count) lines via \(type(of: provider))") + return lines + } + } + } catch { + logger.debug("LyricsKitFetcher: \(type(of: provider)) failed: \(error)") + } + return nil + } + + private func titleIsJapanese(_ title: String) -> Bool { + title.unicodeScalars.contains { + ($0.value >= 0x3040 && $0.value <= 0x309F) || ($0.value >= 0x30A0 && $0.value <= 0x30FF) + } + } + + private func lyricsContainJapanese(_ lines: [LyricsLine]) -> Bool { + lines.contains { line in + line.text.unicodeScalars.contains { + ($0.value >= 0x3040 && $0.value <= 0x309F) || ($0.value >= 0x30A0 && $0.value <= 0x30FF) + } + } + } +} diff --git a/perch/Features/NowPlaying/LyricsStore.swift b/perch/Features/NowPlaying/LyricsStore.swift index f7554a3..0ff53f6 100644 --- a/perch/Features/NowPlaying/LyricsStore.swift +++ b/perch/Features/NowPlaying/LyricsStore.swift @@ -44,17 +44,20 @@ actor LyricsStore { func fetchLyrics(title: String, artist: String, album: String?) async -> [LyricsLine]? { let key = "\(title)|\(artist)" - if let cached = cache[key] { return cached.isEmpty ? nil : cached } + if let cached = cache[key] { return cached } if let lines = await fetchGet(title: title, artist: artist) { cache[key] = lines return lines } - if let lines = await fetchSearch(title: title, artist: artist, album: album) { + if let lines = await fetchSearch(title: title, artist: artist) { + cache[key] = lines + return lines + } + if let lines = await LyricsKitFetcher.shared.fetch(title: title, artist: artist) { cache[key] = lines return lines } - cache[key] = [] // sentinel: no lyrics — skip network on repeat plays return nil } @@ -79,14 +82,12 @@ actor LyricsStore { } } - private func fetchSearch(title: String, artist: String, album: String?) async -> [LyricsLine]? { + private func fetchSearch(title: String, artist: String) async -> [LyricsLine]? { var components = URLComponents(string: "https://lrclib.net/api/search")! - var queryItems = [ + components.queryItems = [ URLQueryItem(name: "track_name", value: title), URLQueryItem(name: "artist_name", value: artist), ] - if let album { queryItems.append(URLQueryItem(name: "album_name", value: album)) } - components.queryItems = queryItems guard let url = components.url else { return nil } do { let (data, response) = try await session.data(from: url) diff --git a/perch/Features/NowPlaying/LyricsView.swift b/perch/Features/NowPlaying/LyricsView.swift index 38c978f..1f48dde 100644 --- a/perch/Features/NowPlaying/LyricsView.swift +++ b/perch/Features/NowPlaying/LyricsView.swift @@ -21,20 +21,18 @@ struct LyricsView: View { Color.clear.frame(height: 6) ForEach(Array(lines.enumerated()), id: \.element.id) { idx, line in Text(line.text) - .font( - .system( - size: idx == activeIndex ? fontSize + 1 : fontSize, - weight: idx == activeIndex ? .semibold : .regular) - ) + .font(.system(size: fontSize, weight: .regular)) .foregroundStyle(.white.opacity(lineOpacity(idx))) - .scaleEffect(idx == activeIndex ? 1.06 : 1.0, anchor: .center) + .scaleEffect(idx == activeIndex ? 1.13 : 1.0, anchor: .center) .multilineTextAlignment(.center) - .animation(.spring(response: 0.35, dampingFraction: 0.82), value: activeIndex) + .lineLimit(2) + .frame(maxWidth: .infinity) + .animation(.spring(response: 0.40, dampingFraction: 0.82), value: activeIndex) .id(line.id) } Color.clear.frame(height: 6) } - .padding(.horizontal, 4) + .padding(.horizontal, 16) } .onChange(of: activeIndex) { _, newIdx in guard let newIdx else { return } diff --git a/perch/Features/NowPlaying/NowPlayingCard.swift b/perch/Features/NowPlaying/NowPlayingCard.swift index cdbb699..314c6f2 100644 --- a/perch/Features/NowPlaying/NowPlayingCard.swift +++ b/perch/Features/NowPlaying/NowPlayingCard.swift @@ -138,7 +138,7 @@ struct NowPlayingCard: View { fontSize: 14 ) } - .frame(maxHeight: 160) + .frame(maxHeight: 200) Divider().background(.white.opacity(0.15)) progressSection } diff --git a/perch/Features/NowPlaying/NowPlayingManager.swift b/perch/Features/NowPlaying/NowPlayingManager.swift index fd0b080..8b69ea6 100644 --- a/perch/Features/NowPlaying/NowPlayingManager.swift +++ b/perch/Features/NowPlaying/NowPlayingManager.swift @@ -127,19 +127,15 @@ final class NowPlayingManager { let album = info["Album"] as? String let durationMs = info["Duration"] as? Double let position = info["Playback Position"] as? Double - let trackNumber = info["Track Number"] as? Int - let popularity = info["Popularity"] as? Int MainActor.assumeIsolated { [weak self] in if playerState == "Stopped" { self?.applyState(nil, source: "Spotify") return } - // Primary: Track ID prefix. Fallback: Track Number=0 + Popularity=0 - // (confirmed by Spotifree, citruspi/Spotify-Notifications via reverse-engineering - // of com.spotify.client.PlaybackStateChanged payload) + // Primary: Track ID prefix. Fallback: empty Name field (ads always omit Name). + // trackNumber/Popularity are unreliable — newer Spotify may omit them. let isAdByTrackId = trackId?.hasPrefix("spotify:ad:") == true - let isAdByFields = - trackNumber == 0 && (popularity == nil || popularity == 0) && playerState == "Playing" + let isAdByFields = name.isEmpty && playerState == "Playing" if isAdByTrackId || isAdByFields { let adState = NowPlayingState( title: "Spotify Ad", artist: "", album: nil, artwork: nil, @@ -152,7 +148,7 @@ final class NowPlayingManager { self?.applyState(adState, source: "Spotify") return } - guard let playerState, !name.isEmpty else { return } + guard let playerState else { return } let state = NowPlayingState( spotifyPlayerState: playerState, title: name, artist: artist, album: album, durationMs: durationMs, position: position diff --git a/perch/Resources/Info.plist b/perch/Resources/Info.plist index 3442253..403b267 100644 --- a/perch/Resources/Info.plist +++ b/perch/Resources/Info.plist @@ -4,5 +4,18 @@ LSUIElement + NSAppTransportSecurity + + NSExceptionDomains + + music.163.com + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + +