diff --git a/.github/MOBILE_DISTRIBUTION.md b/.github/MOBILE_DISTRIBUTION.md new file mode 100644 index 00000000..7942f4b4 --- /dev/null +++ b/.github/MOBILE_DISTRIBUTION.md @@ -0,0 +1,180 @@ +# Mobile Distribution + +This document covers setting up signed mobile builds for iOS (TestFlight / App Store) and Android (signed APK on GitHub Releases). The CI pipeline in `cd.release.yml` handles building and uploading automatically on `v*` tags — this guide covers the one-time setup and the secrets you need to configure. + +## Architecture + +Mobile jobs run alongside the desktop build in `cd.release.yml`: + +``` +validate → build (macOS + Windows) ─┐ + → android (signed APK) ├→ release (draft GitHub Release) + → ios (TestFlight upload) ─┘ +``` + +Both mobile jobs use `continue-on-error: true` so they can never block a desktop release. If signing secrets are absent, the jobs skip cleanly with a warning. + +## Android + +### One-time setup + +#### 1. Generate a release keystore + +```bash +keytool -genkeypair \ + -v \ + -keystore crate-release.keystore \ + -alias crate \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -storepass \ + -keypass \ + -dname "CN=Crate, OU=bbx-audio, O=bbx-audio, L=, S=, C=US" +``` + +Keep this keystore safe — losing it means you cannot update the app on any store that uses it for signing. + +#### 2. Add GitHub secrets + +| Secret | Value | +|--------|-------| +| `ANDROID_KEYSTORE_BASE64` | `base64 -i crate-release.keystore` | +| `ANDROID_KEYSTORE_PASSWORD` | The store password from step 1 | +| `ANDROID_KEY_ALIAS` | `crate` (or whatever alias you used) | +| `ANDROID_KEY_PASSWORD` | The key password from step 1 | + +### How it works + +The CI job decodes the keystore to `$RUNNER_TEMP`, writes `gen/android/key.properties` (gitignored), and `build.gradle.kts` picks it up via a guarded `signingConfigs.release`. Without `key.properties`, Gradle still configures successfully (unsigned path). + +The built APK is renamed to `Crate__android.apk` with a `.sha256` checksum and attached to the GitHub Release. + +### Local signing (optional) + +To sign locally, create `src-tauri/gen/android/key.properties`: + +```properties +storeFile=/path/to/crate-release.keystore +storePassword= +keyAlias=crate +keyPassword= +``` + +Then build: `yarn build:android:apk` + +## iOS + +### One-time setup + +#### 1. Apple Developer Program + +Ensure your team (`883V548CV2`) has an active Apple Developer Program membership. + +#### 2. Create an App Store Distribution certificate + +1. Open Keychain Access → Certificate Assistant → Request a Certificate from a Certificate Authority +2. In [Apple Developer → Certificates](https://developer.apple.com/account/resources/certificates), create a new **Apple Distribution** certificate using the CSR +3. Download and install the `.cer` file +4. Export it as `.p12` from Keychain Access (right-click → Export) + +#### 3. Register the App ID + +In [Apple Developer → Identifiers](https://developer.apple.com/account/resources/identifiers), register: +- Bundle ID: `com.bbx-audio.crate` (production) +- Enable capabilities: none required beyond defaults + +#### 4. Create a provisioning profile + +In [Apple Developer → Profiles](https://developer.apple.com/account/resources/profiles): +- Type: **App Store Connect** +- App ID: `com.bbx-audio.crate` +- Certificate: the Distribution cert from step 2 + +Download the `.mobileprovision` file. + +#### 5. Create an App Store Connect record + +1. Go to [App Store Connect](https://appstoreconnect.apple.com/) +2. Create a new iOS app with bundle ID `com.bbx-audio.crate` +3. Fill in the required metadata (name, description, screenshots, etc.) +4. Enable all territories including EU and Japan + +#### 6. Create an App Store Connect API key + +1. In App Store Connect → Users and Access → Integrations → App Store Connect API +2. Generate a new key with **App Manager** role +3. Download the `.p8` file (only available once) +4. Note the Key ID and Issuer ID + +#### 7. Set up TestFlight + +After the first successful upload, the build appears in TestFlight automatically. To share with external testers: +1. Go to App Store Connect → TestFlight → External Testing +2. Create a group and add testers, or generate a **public link** (global — works for EU + Japan) + +#### 8. Add GitHub secrets + +| Secret | Value | +|--------|-------| +| `IOS_DIST_CERTIFICATE_P12_BASE64` | `base64 -i distribution.p12` | +| `IOS_DIST_CERTIFICATE_PASSWORD` | The .p12 export password | +| `IOS_PROVISIONING_PROFILE_BASE64` | `base64 -i profile.mobileprovision` | +| `IOS_KEYCHAIN_PASSWORD` | Any random password (for the ephemeral CI keychain) | +| `APP_STORE_CONNECT_API_KEY_ID` | Key ID from step 6 | +| `APP_STORE_CONNECT_API_ISSUER_ID` | Issuer ID from step 6 | +| `APP_STORE_CONNECT_API_KEY_P8_BASE64` | `base64 -i AuthKey_XXXX.p8` | + +### How it works + +The CI job creates an ephemeral keychain, imports the distribution certificate, installs the provisioning profile, builds with `tauri ios build --export-method app-store-connect`, and uploads the IPA via `xcrun altool`. The uploaded build lands in TestFlight automatically. + +**Promotion to the public App Store ("Submit for Review") is always a manual action** in App Store Connect. + +### Local builds + +```bash +yarn build:ios:appstore # App Store Connect (TestFlight / App Store) +yarn build:ios:adhoc # Ad-hoc distribution (direct install) +yarn build:android:apk # Android APK +yarn build:android:aab # Android App Bundle (for Play Store) +``` + +## Existing secrets (already configured) + +These secrets are reused from the desktop release pipeline — no action needed: + +| Secret | Purpose | +|--------|---------| +| `GCLOUD_PROJECT_ID` | Cloud sync config (baked into mobile binary) | +| `GCLOUD_WEB_API_KEY` | Cloud sync config | +| `GCLOUD_STORAGE_BUCKET` | Cloud sync config | +| `GCLOUD_OAUTH_CLIENT_ID` | Cloud sync config | +| `GCLOUD_OAUTH_CLIENT_SECRET` | Cloud sync config | +| `GCLOUD_IOS_OAUTH_CLIENT_ID` | iOS OAuth redirect scheme | +| `GCLOUD_ANDROID_OAUTH_CLIENT_ID` | Android OAuth client | +| `APPLE_TEAM_ID` | Apple team identifier | + +## Release flow + +The mobile release flow is identical to desktop — it's triggered by the same `v*` tag: + +1. `./scripts/tag.sh minor` (or `prerelease`, `stage`) +2. Tag push triggers `cd.release.yml` +3. Desktop, Android, and iOS jobs run in parallel +4. Android APK + checksum land on the draft GitHub Release +5. iOS IPA uploads to TestFlight automatically +6. Review and publish the GitHub Release +7. (Optional) Submit the TestFlight build for App Store review in App Store Connect + +## App Store compliance note + +Crate's preview playback obtains audio streams from third-party services (Bandcamp, SoundCloud, YouTube) by scraping their web pages. Apple Guideline **5.2.3** prohibits in-app playback of third-party content without authorization. The build submitted to the App Store includes this functionality. Be aware of the review risk — an accurate App Store description of in-app third-party streaming is what surfaces the guideline. TestFlight and Android sideload APKs are unaffected by this guideline. + +## EU + Japan distribution + +- **TestFlight**: global by default — EU and Japan testers use the same public link +- **App Store**: enable all territories in App Store Connect (no code change) +- **Android APK**: sideloading has no regional restrictions +- **AltStore PAL (EU, optional)**: requires accepting Apple's EU alternative distribution terms (Core Technology Fee). Not set up by default. +- **F-Droid**: not possible — Crate's PolyForm Shield license is not FOSS diff --git a/.github/RELEASE_STRATEGY.md b/.github/RELEASE_STRATEGY.md index 6d5e470d..d263b647 100644 --- a/.github/RELEASE_STRATEGY.md +++ b/.github/RELEASE_STRATEGY.md @@ -117,16 +117,22 @@ The changelog (`CHANGELOG.md`) follows [Keep a Changelog](https://keepachangelog Prerelease increments (`./scripts/tag.sh prerelease`) skip the changelog entirely — changes continue accumulating under `[Unreleased]` until the next `prepare` or `graduate`. +## Mobile Distribution + +iOS (TestFlight / App Store) and Android (signed APK) builds run alongside the desktop build in the same `cd.release.yml` workflow. See [MOBILE_DISTRIBUTION.md](MOBILE_DISTRIBUTION.md) for setup instructions, required GitHub secrets, and the full signing flow. + ## Post-Release Checklist After CI completes: 1. Go to [GitHub Releases](https://github.com/blackboxaudio/crate/releases) and review the draft -2. Verify artifacts are attached (DMG for macOS, MSI/EXE for Windows) +2. Verify artifacts are attached (DMG for macOS, MSI/EXE for Windows, APK + sha256 for Android) 3. Review release notes 4. Publish the release 5. Verify download links work 6. Verify auto-updater picks up the new version (check `latest.json` in GCS) +7. Verify the iOS build appeared in TestFlight (App Store Connect → TestFlight) +8. `sha256sum -c` the Android APK from the release, then sideload to verify ## Utilities diff --git a/.github/workflows/cd.release.yml b/.github/workflows/cd.release.yml index 8d719cc4..bf06fec7 100644 --- a/.github/workflows/cd.release.yml +++ b/.github/workflows/cd.release.yml @@ -158,7 +158,7 @@ jobs: - name: Build Tauri app (macOS Universal) if: matrix.os == 'macos-latest' - run: yarn tauri build --config src-tauri/tauri.${{ needs.validate.outputs.channel == 'staging' && 'staging' || 'prod' }}.conf.json --target universal-apple-darwin + run: yarn tauri build --config src-tauri/tauri.${{ needs.validate.outputs.channel == 'staging' && 'staging' || 'prod' }}.conf.json --target universal-apple-darwin --features desktop env: CRATE_ENV: ${{ needs.validate.outputs.channel }} # Cloud sync config — public client identifiers, baked in at compile time @@ -181,7 +181,7 @@ jobs: - name: Build Tauri app (Windows) if: matrix.os == 'windows-latest' - run: yarn tauri build --config src-tauri/tauri.${{ needs.validate.outputs.channel == 'staging' && 'staging' || 'prod' }}.conf.json + run: yarn tauri build --config src-tauri/tauri.${{ needs.validate.outputs.channel == 'staging' && 'staging' || 'prod' }}.conf.json --features desktop env: CRATE_ENV: ${{ needs.validate.outputs.channel }} # Cloud sync config — public client identifiers, baked in at compile time @@ -214,9 +214,301 @@ jobs: src-tauri/target/release/bundle/nsis/*.exe src-tauri/target/release/bundle/nsis/*.exe.sig + android: + name: Build Android APK + needs: validate + # TEMP: Android release build disabled for now — remove this `if: false` to re-enable. While + # disabled this job reports `skipped`, which the `release` gate treats as non-blocking. Once + # re-enabled, an Android build *failure* WILL block the release — a release is only published + # when every build (desktop and mobile) succeeds. + if: false + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'yarn' + + - name: Setup Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-02-19 + targets: aarch64-linux-android + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + shared-key: mobile-android + cache-targets: true + cache-on-failure: true + save-if: 'false' + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + # Vendored OpenSSL + SQLCipher need NDK clang/ar for cross-compilation (mirrored from ci.build.yml). + - name: Configure Android NDK toolchain + run: | + NDK="$ANDROID_NDK_LATEST_HOME" + TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64" + echo "$TOOLCHAIN/bin" >> "$GITHUB_PATH" + { + echo "ANDROID_NDK_ROOT=$NDK" + echo "ANDROID_NDK_HOME=$NDK" + echo "CC_aarch64-linux-android=$TOOLCHAIN/bin/aarch64-linux-android24-clang" + echo "CXX_aarch64-linux-android=$TOOLCHAIN/bin/aarch64-linux-android24-clang++" + echo "AR_aarch64-linux-android=$TOOLCHAIN/bin/llvm-ar" + echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$TOOLCHAIN/bin/aarch64-linux-android24-clang" + } >> "$GITHUB_ENV" + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Write cloud sync config + env: + GCLOUD_PROJECT_ID: ${{ secrets.GCLOUD_PROJECT_ID }} + GCLOUD_WEB_API_KEY: ${{ secrets.GCLOUD_WEB_API_KEY }} + GCLOUD_STORAGE_BUCKET: ${{ secrets.GCLOUD_STORAGE_BUCKET }} + GCLOUD_OAUTH_CLIENT_ID: ${{ secrets.GCLOUD_OAUTH_CLIENT_ID }} + GCLOUD_OAUTH_CLIENT_SECRET: ${{ secrets.GCLOUD_OAUTH_CLIENT_SECRET }} + GCLOUD_ANDROID_OAUTH_CLIENT_ID: ${{ secrets.GCLOUD_ANDROID_OAUTH_CLIENT_ID }} + GCLOUD_IOS_OAUTH_CLIENT_ID: ${{ needs.validate.outputs.channel == 'staging' && secrets.GCLOUD_IOS_OAUTH_CLIENT_ID_STAGING || secrets.GCLOUD_IOS_OAUTH_CLIENT_ID }} + # Firebase App IDs for mobile App Check (#139). The dev-only appcheck_debug_token is + # deliberately NOT injected in release — release uses native App Attest / Play Integrity. + GCLOUD_FIREBASE_IOS_APP_ID: ${{ needs.validate.outputs.channel == 'staging' && secrets.GCLOUD_FIREBASE_IOS_APP_ID_STAGING || secrets.GCLOUD_FIREBASE_IOS_APP_ID }} + GCLOUD_FIREBASE_ANDROID_APP_ID: ${{ secrets.GCLOUD_FIREBASE_ANDROID_APP_ID }} + run: node scripts/write-cloud-config.mjs + + - name: Determine signing availability + id: signing + env: + HAS_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 != '' }} + run: | + if [[ "$HAS_KEYSTORE" == "true" ]]; then + echo "signed=true" >> "$GITHUB_OUTPUT" + else + echo "signed=false" >> "$GITHUB_OUTPUT" + echo "::warning::Android signing secrets not configured — building unsigned APK" + fi + + - name: Decode keystore and write key.properties + if: steps.signing.outputs.signed == 'true' + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore" + { + echo "storeFile=$RUNNER_TEMP/release.keystore" + echo "storePassword=$KEYSTORE_PASSWORD" + echo "keyAlias=$KEY_ALIAS" + echo "keyPassword=$KEY_PASSWORD" + } > src-tauri/gen/android/key.properties + + - name: Write mobile icons + env: + CHANNEL: ${{ needs.validate.outputs.channel }} + run: node scripts/write-mobile-icons.mjs --platform android --channel "${CHANNEL/production/prod}" + + - name: Build Android APK + env: + CRATE_ENV: ${{ needs.validate.outputs.channel }} + run: yarn tauri android build --apk --features mobile + + - name: Prepare release artifacts + id: artifacts + env: + VERSION: ${{ needs.validate.outputs.version }} + run: | + APK=$(find src-tauri/gen/android/app/build/outputs/apk -name "*.apk" -path "*/release/*" | head -1) + if [[ -z "$APK" ]]; then + echo "::error::No release APK found" + exit 1 + fi + DEST="Crate_${VERSION}_android.apk" + cp "$APK" "$DEST" + sha256sum "$DEST" > "${DEST}.sha256" + echo "apk=$DEST" >> "$GITHUB_OUTPUT" + echo "sha256=${DEST}.sha256" >> "$GITHUB_OUTPUT" + + - name: Upload Android artifacts + uses: actions/upload-artifact@v4 + with: + name: release-android + path: | + ${{ steps.artifacts.outputs.apk }} + ${{ steps.artifacts.outputs.sha256 }} + + ios: + name: Build iOS (TestFlight) + needs: validate + # macos-latest gives macOS Tahoe + Xcode 26, REQUIRED: App Store Connect rejects uploads built + # with anything older than the iOS 26 SDK. Xcode 26's `$(TOOLCHAIN_DIR)` resolves to a Metal + # cryptex toolchain that lacks the Swift back-compat libs (which broke linking Tauri's Swift + # plugins) — fixed by repointing LIBRARY_SEARCH_PATHS at XcodeDefault.xctoolchain in the + # pbxproj + project.yml. + runs-on: macos-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Select latest stable Xcode (>= 26 for the iOS 26 SDK) + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 'latest-stable' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'yarn' + + - name: Setup Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-02-19 + targets: aarch64-apple-ios + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + shared-key: mobile-ios + cache-targets: true + cache-on-failure: true + save-if: 'false' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Write cloud sync config + env: + GCLOUD_PROJECT_ID: ${{ secrets.GCLOUD_PROJECT_ID }} + GCLOUD_WEB_API_KEY: ${{ secrets.GCLOUD_WEB_API_KEY }} + GCLOUD_STORAGE_BUCKET: ${{ secrets.GCLOUD_STORAGE_BUCKET }} + GCLOUD_OAUTH_CLIENT_ID: ${{ secrets.GCLOUD_OAUTH_CLIENT_ID }} + GCLOUD_OAUTH_CLIENT_SECRET: ${{ secrets.GCLOUD_OAUTH_CLIENT_SECRET }} + GCLOUD_ANDROID_OAUTH_CLIENT_ID: ${{ secrets.GCLOUD_ANDROID_OAUTH_CLIENT_ID }} + GCLOUD_IOS_OAUTH_CLIENT_ID: ${{ needs.validate.outputs.channel == 'staging' && secrets.GCLOUD_IOS_OAUTH_CLIENT_ID_STAGING || secrets.GCLOUD_IOS_OAUTH_CLIENT_ID }} + # Firebase App IDs for mobile App Check (#139). The dev-only appcheck_debug_token is + # deliberately NOT injected in release — release uses native App Attest / Play Integrity. + GCLOUD_FIREBASE_IOS_APP_ID: ${{ needs.validate.outputs.channel == 'staging' && secrets.GCLOUD_FIREBASE_IOS_APP_ID_STAGING || secrets.GCLOUD_FIREBASE_IOS_APP_ID }} + GCLOUD_FIREBASE_ANDROID_APP_ID: ${{ secrets.GCLOUD_FIREBASE_ANDROID_APP_ID }} + run: node scripts/write-cloud-config.mjs + + - name: Write iOS Info.plist + env: + GCLOUD_IOS_OAUTH_CLIENT_ID: ${{ needs.validate.outputs.channel == 'staging' && secrets.GCLOUD_IOS_OAUTH_CLIENT_ID_STAGING || secrets.GCLOUD_IOS_OAUTH_CLIENT_ID }} + run: yarn ios:plist:write + + - name: Write mobile icons + env: + CHANNEL: ${{ needs.validate.outputs.channel }} + run: node scripts/write-mobile-icons.mjs --platform ios --channel "${CHANNEL/production/prod}" + + - name: Set iOS bundle id for channel + env: + CHANNEL: ${{ needs.validate.outputs.channel }} + run: node scripts/write-ios-bundle-id.mjs --channel "${CHANNEL/production/prod}" + + - name: Determine signing availability + id: signing + env: + HAS_CERT: ${{ secrets.IOS_DIST_CERTIFICATE_P12_BASE64 != '' }} + HAS_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8_BASE64 != '' }} + run: | + if [[ "$HAS_CERT" == "true" && "$HAS_API_KEY" == "true" ]]; then + echo "signed=true" >> "$GITHUB_OUTPUT" + else + echo "signed=false" >> "$GITHUB_OUTPUT" + echo "::warning::iOS distribution signing secrets not configured — skipping iOS build" + fi + + - name: Configure iOS signing + if: steps.signing.outputs.signed == 'true' + env: + IOS_PROVISIONING_PROFILE_NAME: ${{ needs.validate.outputs.channel == 'staging' && secrets.IOS_PROVISIONING_PROFILE_NAME_STAGING || secrets.IOS_PROVISIONING_PROFILE_NAME }} + # Tauri ignores project.yml and uses the committed pbxproj as-is, applying manual-signing + # MODE but never selecting a profile -> "requires a provisioning profile". This writes the + # profile + Apple Distribution identity into the pbxproj on the runner (channel-aware; not + # committed, so local dev keeps automatic signing). + run: node scripts/write-ios-signing.mjs + + - name: Build iOS app + if: steps.signing.outputs.signed == 'true' + env: + CRATE_ENV: ${{ needs.validate.outputs.channel }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # Tauri reads the iOS signing team from APPLE_DEVELOPMENT_TEAM (the build warned it was + # unset); mirror APPLE_TEAM_ID into it so codesigning picks up the team. + APPLE_DEVELOPMENT_TEAM: ${{ secrets.APPLE_TEAM_ID }} + # Tauri 2 does the keychain import + provisioning-profile install + signing itself from + # these exact env vars (https://v2.tauri.app/distribute/sign/ios). Without them it falls + # back to AUTOMATIC signing and fails in CI with "No Accounts" / "No profiles found". The + # profile is channel-aware (staging vs prod); no profile *name* is needed — Tauri reads + # the profile from the file itself. + IOS_CERTIFICATE: ${{ secrets.IOS_DIST_CERTIFICATE_P12_BASE64 }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DIST_CERTIFICATE_PASSWORD }} + IOS_MOBILE_PROVISION: ${{ needs.validate.outputs.channel == 'staging' && secrets.IOS_PROVISIONING_PROFILE_BASE64_STAGING || secrets.IOS_PROVISIONING_PROFILE_BASE64 }} + # Pass the channel's Tauri config so the iOS bundle id matches the channel + # (staging -> com.bbx-audio.crate.staging, production -> com.bbx-audio.crate). Without it, + # `tauri ios build` uses the base tauri.conf.json identifier (com.bbx-audio.crate) for every + # channel, which is why staging was signing against the production bundle. Mirrors the + # desktop build and the local build:staging:ios script. + run: yarn tauri ios build --config src-tauri/tauri.${{ needs.validate.outputs.channel == 'staging' && 'staging' || 'prod' }}.conf.json --export-method app-store-connect --features mobile + + - name: Upload to App Store Connect (TestFlight) + if: steps.signing.outputs.signed == 'true' + env: + API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8_BASE64 }} + API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }} + run: | + mkdir -p ~/.private_keys + echo "$API_KEY_BASE64" | base64 --decode -o ~/.private_keys/AuthKey_${API_KEY_ID}.p8 + + IPA=$(find src-tauri/gen/apple/build -name "*.ipa" | head -1) + if [[ -z "$IPA" ]]; then + echo "::error::No IPA found after build" + exit 1 + fi + + xcrun altool --upload-app \ + -f "$IPA" \ + -t ios \ + --apiKey "$API_KEY_ID" \ + --apiIssuer "$API_ISSUER_ID" + + - name: Cleanup keychain + if: always() + run: security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db" 2>/dev/null || true + release: name: Create Release - needs: [validate, build] + needs: [validate, build, android, ios] + # Publish a release only when every build succeeded — desktop AND mobile. A build that is + # intentionally skipped (the Android job's `if: false`, or an iOS run without signing secrets) + # is treated as non-blocking; a genuine build *failure* blocks the release. `always()` is + # required so this job still evaluates when a mobile job is skipped. + if: >- + always() + && needs.validate.result == 'success' + && needs.build.result == 'success' + && (needs.android.result == 'success' || needs.android.result == 'skipped') + && (needs.ios.result == 'success' || needs.ios.result == 'skipped') runs-on: ubuntu-latest permissions: contents: write @@ -248,6 +540,8 @@ jobs: artifacts/release-windows/**/*.msi.sig artifacts/release-windows/**/*.exe artifacts/release-windows/**/*.exe.sig + artifacts/release-android/*.apk + artifacts/release-android/*.sha256 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.build.yml b/.github/workflows/ci.build.yml index 56cf4512..f89acca8 100644 --- a/.github/workflows/ci.build.yml +++ b/.github/workflows/ci.build.yml @@ -49,8 +49,88 @@ jobs: - name: Build Tauri app (macOS) if: matrix.os == 'macos-latest' - run: yarn tauri build --target universal-apple-darwin --config '{"bundle":{"createUpdaterArtifacts":false}}' + run: yarn tauri build --target universal-apple-darwin --config '{"bundle":{"createUpdaterArtifacts":false}}' --features desktop - name: Build Tauri app (Windows) if: matrix.os == 'windows-latest' - run: yarn tauri build --config '{"bundle":{"createUpdaterArtifacts":false}}' + run: yarn tauri build --config '{"bundle":{"createUpdaterArtifacts":false}}' --features desktop + + # Mobile targets are compile-checked only (cargo check, no app bundle) so a desktop change + # that leaks into shared code can't silently break the mobile build. The feature flags from + # #141 exclude desktop-only code via `--no-default-features --features mobile`. + ios: + runs-on: macos-latest + defaults: + run: + working-directory: src-tauri + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-02-19 + targets: aarch64-apple-ios + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + shared-key: mobile-ios + cache-targets: true + cache-on-failure: true + save-if: ${{ github.ref == 'refs/heads/develop' }} + + - name: Check iOS compilation + run: cargo check --target aarch64-apple-ios --no-default-features --features mobile + + android: + runs-on: ubuntu-latest + defaults: + run: + working-directory: src-tauri + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-02-19 + targets: aarch64-linux-android + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + shared-key: mobile-android + cache-targets: true + cache-on-failure: true + save-if: ${{ github.ref == 'refs/heads/develop' }} + + # Vendored OpenSSL + SQLCipher are built from C source, so cross-compiling to Android + # needs the NDK clang/ar toolchain wired up for both `cc` and openssl-src. + - name: Configure Android NDK toolchain + run: | + NDK="$ANDROID_NDK_LATEST_HOME" + TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64" + echo "$TOOLCHAIN/bin" >> "$GITHUB_PATH" + { + echo "ANDROID_NDK_ROOT=$NDK" + echo "ANDROID_NDK_HOME=$NDK" + echo "CC_aarch64-linux-android=$TOOLCHAIN/bin/aarch64-linux-android24-clang" + echo "CXX_aarch64-linux-android=$TOOLCHAIN/bin/aarch64-linux-android24-clang++" + echo "AR_aarch64-linux-android=$TOOLCHAIN/bin/llvm-ar" + echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$TOOLCHAIN/bin/aarch64-linux-android24-clang" + } >> "$GITHUB_ENV" + + # `tauri::mobile_entry_point` turns the app identifier's last segment into a Rust + # identifier for the Android JNI binding. The desktop bundle id `com.bbx-audio.crate` + # ends in `crate` — a reserved keyword — so the Android target won't compile. Override + # only the compile-check identifier here; the real desktop id is untouched. Choosing the + # production Android identifier is M.002 follow-up work. + - name: Check Android compilation + env: + TAURI_CONFIG: '{"identifier":"com.bbx-audio.crateapp"}' + run: cargo check --target aarch64-linux-android --no-default-features --features mobile diff --git a/.github/workflows/ci.lint.yml b/.github/workflows/ci.lint.yml index 2a5cda01..45e7cae7 100644 --- a/.github/workflows/ci.lint.yml +++ b/.github/workflows/ci.lint.yml @@ -60,6 +60,24 @@ jobs: - name: Run type check run: yarn check:svelte + svelte-check-mobile: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run type check + run: yarn check:svelte:mobile + rust-format: runs-on: macos-latest defaults: @@ -103,7 +121,7 @@ jobs: save-if: ${{ github.ref == 'refs/heads/develop' }} - name: Run clippy - run: cargo clippy -- -D warnings + run: cargo clippy --features desktop -- -D warnings rust-audit: runs-on: ubuntu-latest diff --git a/.github/workflows/test.release.yml b/.github/workflows/test.release.yml index 480ee02e..60c85385 100644 --- a/.github/workflows/test.release.yml +++ b/.github/workflows/test.release.yml @@ -59,7 +59,7 @@ jobs: - name: Build Tauri app (macOS Universal) if: matrix.os == 'macos-latest' - run: yarn tauri build --config src-tauri/tauri.${{ inputs.channel == 'staging' && 'staging' || 'prod' }}.conf.json --target universal-apple-darwin + run: yarn tauri build --config src-tauri/tauri.${{ inputs.channel == 'staging' && 'staging' || 'prod' }}.conf.json --target universal-apple-darwin --features desktop env: CRATE_ENV: ${{ inputs.channel }} # Cloud sync config — public client identifiers, baked in at compile time @@ -82,7 +82,7 @@ jobs: - name: Build Tauri app (Windows) if: matrix.os == 'windows-latest' - run: yarn tauri build --config src-tauri/tauri.${{ inputs.channel == 'staging' && 'staging' || 'prod' }}.conf.json + run: yarn tauri build --config src-tauri/tauri.${{ inputs.channel == 'staging' && 'staging' || 'prod' }}.conf.json --features desktop env: CRATE_ENV: ${{ inputs.channel }} # Cloud sync config — public client identifiers, baked in at compile time diff --git a/.gitignore b/.gitignore index bf069d76..de75c57a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ .DS_Store node_modules -/build -/.svelte-kit +build/ +.svelte-kit/ /package .env .env.* diff --git a/.prettierrc b/.prettierrc index 25d3f770..3a9ed947 100644 --- a/.prettierrc +++ b/.prettierrc @@ -16,5 +16,5 @@ } } ], - "tailwindStylesheet": "./src/style.css" + "tailwindStylesheet": "./apps/desktop/src/style.css" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c106a89..1457f7cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- Added a first-run onboarding flow to the mobile app: a one-time, dismissible carousel that welcomes you, explains adding and previewing releases, offers an optional "Sign in to sync with desktop" step, and lets you pick a theme and accent color — the app is fully usable standalone with zero sync setup, and onboarding can be skipped at any point. The theme/accent step is skipped only when signing in restores your existing appearance settings from another device +- Added branded native launch screens to the mobile app on iOS and Android (the crate mark on the app background, respecting light/dark mode) so there's no plain-white flash before the app loads +- Added a "New" badge to mobile discovery releases surfaced by a followed source: newly surfaced releases are marked everywhere they appear (the discovery feed, playlists, followed-source feeds, and the release detail) until you open or play them, at which point the badge clears automatically and syncs across devices +- Added metadata refresh to the mobile release detail: opening a release that has no tracks yet now auto-fetches them from the source, and a "Refresh Metadata" action in the release's menu (or a pull-down on the release) re-fetches metadata and tracks on demand +- Added pull-to-refresh across the mobile app: pull down on the discovery feed or Following tab to check all followed sources for new releases, on a followed source's release list to check just that source, and on a release to refresh its metadata — with freshly surfaced releases reloading into the feed inline +- Added the iOS tab-bar convention to the mobile app: re-tapping the active tab scrolls its list back to the top, or — when a drill-in (release, playlist, tag, or followed-source detail) is open — backs out of it a level at a time +- Added offline downloads to the mobile app: a "Download for Offline" action on a release's menu pre-fetches its audio to the device so it plays with no network (airplane mode), a "Downloaded" badge marks fully-cached releases, and "Remove Download" reclaims the space; the on-device audio cache is capped at 500 MB and evicts the least-recently-played tracks when full +- Added offline album art to the mobile app: release covers are saved to the device the first time they're shown, so they still appear with no network (airplane mode); mobile Settings now shows the artwork cache size next to the audio cache, each can be cleared separately, and both have an adjustable size limit that evicts the least-recently-shown / least-recently-played items when full +- Added smart-playlist editing to the mobile app: a smart playlist's long-press menu now offers "Edit Smart Playlist", opening the rule editor prefilled with the playlist's name, match mode, and conditions — and a release limit set on desktop survives a mobile edit untouched +- Added restoring your place in the mobile app across restarts: the app reopens on the tab you left, inside the same playlist folder (the folder path now also survives switching tabs), with any open release, playlist, tag, or followed-source screen restored in place, and the discovery feed scrolled back to the release you were looking at — anything deleted from another device in the meantime is skipped gracefully +- Added swipe-to-skip and track-change animations to the mobile full-screen player: swipe the cover left/right to go to the next/previous track — the neighboring track's cover follows your finger in, with a rubber-band stop when there's nothing further — and every track change (buttons, swipes, auto-advance, lock-screen skips) now slides the cover through like a card deck while the title and artist ticker-roll in after it; moving to a different release also slowly cross-dissolves the blurred backdrop, while tracks of the same release keep it perfectly still +- Added Android support to the mobile app: the encrypted library database now opens on-device (its key is protected by the Android Keystore), previews keep playing with the screen off or the app backgrounded — with lock-screen and notification playback controls, a progress bar, and polite pausing/ducking when another app takes over the audio — URLs shared from other apps (Bandcamp, SoundCloud, a browser) open Crate with the add-release form prefilled, the hardware back button/gesture closes the topmost screen or sheet a level at a time like a native app, and each release channel (dev, staging, production) installs side-by-side as its own app with its own icon and name; Android release APKs are now built and published alongside the other platforms + +### Changed + +- Gave the mobile app iOS-style large-title navigation: each tab (Discovery, Following, Playlists, Tags) shows a large title at the top of its content that scrolls away and collapses into a compact centered title in the top bar as you scroll, with the per-tab search / sort / filter controls scrolling away alongside it. The brand mark, account chip, and settings gear stay pinned in the top bar. Following can be filtered by name or URL and Playlists by name, matching the existing Discovery search +- Changed the mobile app to render in the native system font (San Francisco on iOS) instead of a network-served web font, and to honor the system Dynamic Type text-size setting so text scales with your accessibility preference — the app no longer fetches a font over the network, so it paints instantly and works offline +- Locked the mobile app to portrait orientation +- Moved mobile Settings out of the bottom tab bar into a right-side drawer opened from a gear button in the top bar, so the four remaining tabs (Discovery, Following, Playlists, Tags) have room for their labels in every language; the mini-player is hidden while the settings drawer is open +- Changed the mobile cloud sign-in button to the standard Google-branded "Sign in with Google" button +- Changed the mobile Following tab to check sources via pull-to-refresh instead of a "Check all" button (checking a single source stays on its long-press menu) +- Polished the mobile Playlists tab to match the rest of the app: the search/add toolbar stays pinned while lists scroll, rows show a release/item count subtitle in the standard list typography, the first load shows a skeleton instead of flashing "No playlists yet", empty states gained an icon and a create shortcut, reorder mode keeps rows visually identical to normal browsing (and the list can now be scrolled by touch while reordering), and smart playlists no longer offer the reorder and remove actions that don't apply to rule-based lists +- Polished the mobile Tags tab to match the rest of the app: each category header now shows a visible "…" menu button (long-press still works), tags can be moved to another category from their long-press menu, tag chips are comfortably larger to tap, empty categories and the empty tab now explain the next step with a create shortcut, adding and removing tags or categories animates smoothly, and a tag's release feed supports pull-to-refresh +- Enabled iOS App Attest device attestation for staging and production builds (Firebase App Check): the App Attest entitlement is now stamped per release channel, while dev builds continue to use an App Check debug token +- Followed-source refresh now throttles its network checks to stay under Bandcamp, SoundCloud, and Discogs rate limits: each source's page is re-fetched at most once every 30 minutes on automatic refreshes (an explicit single-source "check now" still runs immediately), and a source that returns a rate-limit response is backed off with an increasing delay before it's checked again +- Changed mobile cloud sync to run when the app launches and each time it returns to the foreground, instead of polling continuously — the desktop app keeps its always-on background sync, but the phone no longer ticks in the background, saving battery +- Added opportunistic background cloud sync on iOS: beyond syncing on launch and when returning to the foreground, the app now also syncs occasionally in the background when iOS schedules it (Background App Refresh), still with no always-on poll, so edits from your other devices are more often already waiting when you open the app +- Mobile releases added by URL while offline now retry automatically with an increasing (exponential) backoff delay once metadata can't be fetched, in addition to retrying the moment connectivity returns, so a transient failure no longer leaves an item stuck until the app is restarted + +### Fixed + +- Fixed the mobile app going permanently white (and feeling frozen) under memory pressure with large libraries: releases now load in pages instead of one giant payload so the screen stays responsive and memory spikes are gone, syncing thousands of releases no longer blocks taps and playback while it writes (user actions take priority over sync work), cover downloads while scrolling are limited to a few at a time, sync-triggered refreshes wait until you stop touching the screen, and if iOS still kills the app's web view it now reloads itself automatically (with audio playing through uninterrupted) instead of staying blank until relaunch +- Fixed the mobile player scrubber snapping back to the old position right after seeking on iOS +- Fixed the mobile "Sign in with Google" button spinning forever after consent on TestFlight/release builds: a stalled or crashed App Check device attestation could leave the sign-in request permanently pending — native attestation and Keychain calls now run off the async runtime so their timeouts always apply, an attestation panic degrades to signing in without an attestation header, sign-in as a whole is bounded to two minutes, and a failure now shows its error under the sign-in button instead of spinning silently; mobile app logs are also now visible on-device (iOS Console.app / Android logcat) for diagnosing issues like this +- Fixed mobile bottom sheets being covered by the on-screen keyboard (most noticeably the "Add Release" sheet, where the URL field and action buttons were hidden): a sheet now lifts to rest just above the keyboard when a field is focused and drops back down when it's dismissed +- Fixed cloud sync flipping to "Offline — changes will sync when you reconnect" shortly after signing in (and periodically afterwards) despite a working connection: a background sync could reuse an idle connection the server had already closed, and the failed attempt read as lost connectivity — idle connections are now discarded before the server closes them +- Fixed mobile (iOS) cloud sign-in hanging on "Signing in…" and never completing, from two causes: the Google OAuth redirect scheme is no longer registered in the iOS app's `Info.plist` (it was intercepting the callback and preventing the native web-auth session from resolving), and App Check token minting on the sign-in path is now bounded by a timeout with a short failure cooldown so a slow or unregistered device attestation degrades to "no header" instead of stalling sign-in + +## [0.3.0-staging.1] - 2026-06-24 + +### Added + +- Added signed mobile distribution: iOS builds upload to TestFlight automatically on release tags, and signed Android APKs with SHA-256 checksums are attached to GitHub Releases +- Added native cloud sync sign-in on mobile (iOS/Android) using the platform's secure web-auth session (ASWebAuthenticationSession / Custom Tabs) instead of the desktop loopback flow; mobile syncs discovery data only, never the local library +- Added secure SQLCipher database key storage on iOS using the Keychain (device-only, after-first-unlock accessibility) via a new platform `KeyProvider` abstraction; desktop keeps its local key-file behavior and the unencrypted-to-encrypted database migration is now desktop-only +- Added the mobile app's navigation shell: a Discovery main view with a left drawer (playlists and tags) opened by the hamburger button or a left-edge swipe, a right drawer (appearance and cloud sign-in) opened by the settings button, and touch-optimized base components +- Added a branded launch splash screen and a spinning-record loading indicator to the mobile app +- Added the mobile app's branded launcher icons (iOS app icon and Android adaptive icon) with per-channel variants matching desktop (development, staging, production) +- Added the mobile release detail screen (artwork, metadata, one-tap open in the source app — Bandcamp, SoundCloud, YouTube, Discogs — the track list, an assignable tag picker, and inline-editable notes) with an iOS-style left-edge swipe-back, plus an integrated preview player — a liquid-glass mini-player bar with progress whose artwork morphs up into a full-screen player (blurred album-art backdrop, drag-to-dismiss, scrubber, shuffle, previous/next, like, and a ±10% tempo control) whose shuffle and previous/next span the whole discovery feed on screen rather than just the current release — and, on iOS, a native lock-screen transport (play/pause, previous/next, and scrubbing that keep working while the screen is locked, with auto-resume after call interruptions) powered by a native AVPlayer engine +- Added the mobile discovery feed: a virtualized, searchable list of release cards (artwork, artist, title, label) with tag-filter chips (AND/OR), sorting (date added, artist, title, label), swipe-to-delete, and long-press multi-select for batch delete and batch tag assignment +- Added an account & cloud-sync control to the mobile top bar: a live status chip (synced, syncing, offline, or error) showing your account avatar that opens a sheet to sync now, see when the library last synced, or sign out — and the bar now shows the active section's title (Playlists, Tags, Settings) in place of the wordmark +- Added a first-class playback queue to the mobile preview player: an "Up Next" sheet (opened from the full-screen player) listing songs you've queued ahead of what the discovery feed will play next, with "Play next" and "Add to queue" for a single track from each track's menu in the release detail or for a whole release (all of its tracks, in order) from a feed card's long-press menu or swipe action, drag-to-reorder and swipe-to-remove for queued songs, and persistence across relaunch; queued songs always play before the shuffle/sequence resumes and are never reshuffled, and on iOS a lazily-resolved native-engine window keeps queued songs and cross-release advances gapless while the screen is locked +- Added restoring your listening state on the mobile app's launch: the preview you were last playing reappears in the mini-player — with its track progress, shuffle, and tempo — paused and ready to resume, without auto-opening the full-screen player +- Added a mobile settings page with cloud-sync account management, audio preview cache controls, and app info, plus live auto-sync so discovery changes from another device appear instantly without a tab-switch +- Added discovery playlists on mobile: create, rename, and delete playlists, smart playlists (built with a touch rule editor that filters on title, artist, label, tags, and more with a live match count), and folders with Spotify-style 2×2 cover-art thumbnails and animated, directional drill-in folder navigation on the Playlists tab, tap a playlist to view its releases in a full-screen detail overlay (the same fast, virtualized list as the discovery feed), add releases from the feed or release detail via a long-press context menu or multi-select batch action — picking a destination in an Add-to-Playlist sheet you can browse by folder or search by name, with the same 2×2 cover thumbnails throughout — remove releases with swipe or context menu, and long-press-drag to reorder releases in a dedicated reorder mode — all backed by a new `reorder_playlist_releases` backend command and bidirectional cloud sync +- Added add, edit, and bulk-import for discovery releases on mobile: paste a release URL to auto-fetch metadata with an editable preview, paste a page URL (Bandcamp artist/label, Discogs artist/label) to scan and bulk-import, edit release metadata via a dedicated sheet, clipboard prefill on open, and an offline add queue that persists pending URLs and drains them automatically when connectivity returns +- Added tag management on the mobile Tags tab so the app works standalone: browse your tag categories as sections of color-coded chips, create categories (auto-assigned a color) and tags, and rename, recolor (a 10-swatch picker), or delete them via iOS-style long-press menus; tap a tag chip to drill into a full-screen feed of the releases carrying it (the same fast, virtualized list as the discovery feed, with preview playback, long-press actions, and multi-select batch tagging) — all backed by the existing tag commands and bidirectional cloud sync, so categories and tags created on mobile converge with desktop +- Added a Following tab to the mobile app: browse the artists and labels you follow with their new-release counts, follow a source by pasting its page URL, check for new releases individually or all at once, and unfollow via iOS-style long-press menus; tap a followed source to drill into a full-screen feed of its releases (the same fast, virtualized list as the discovery feed, with preview playback and long-press actions), plus an inline Follow action on any discovery release's context menu to follow its artist or label — and new releases from your follows keep surfacing in the discovery feed +- Added Firebase App Check attestation to mobile cloud sync: every Firestore, Storage, and sign-in request now carries a token proving it came from the genuine app on a genuine device (Apple App Attest on iOS, Play Integrity on Android), hardening the backend against replayed credentials; desktop is unaffected and the feature ships in monitoring mode + +### Changed + +- Changed the mobile long-press menus (discovery releases, release-detail tracks, and playlists/folders) from bottom action sheets to native-style iOS context menus: the pressed item lifts above a blurred, dimmed backdrop while a spring-in action menu anchors to it (placed below or above as space allows), with a haptic on open and tap-outside / swipe-down to dismiss +- Prepared the backend to compile for mobile targets (iOS/Android) by gating desktop-only services (audio playback, USB export/sync, file import, track analysis, media keys, device detection) behind a default-on `desktop` Cargo feature, keeping desktop builds unchanged +- Set up the iOS application project (Tauri mobile) so the mobile app builds and runs on iOS, with the background-audio mode and cloud sign-in OAuth redirect scheme configured + ## [0.2.9] - 2026-06-09 ### Added @@ -152,7 +220,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Waveform display with cue point management - Search and filter across entire collection -[Unreleased]: https://github.com/blackboxaudio/crate/compare/v0.2.9...HEAD +[Unreleased]: https://github.com/blackboxaudio/crate/compare/v0.3.0-staging.1...HEAD +[0.3.0-staging.1]: https://github.com/blackboxaudio/crate/compare/v0.2.9...v0.3.0-staging.1 [0.2.9]: https://github.com/blackboxaudio/crate/compare/v0.2.9...v0.2.9 [0.2.9]: https://github.com/blackboxaudio/crate/compare/v0.2.8...v0.2.9 [0.2.8]: https://github.com/blackboxaudio/crate/compare/v0.2.7...v0.2.8 diff --git a/README.md b/README.md index 308ef52f..9648aa62 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,52 @@ Output binaries are placed in `src-tauri/target/release/bundle/`. Platform targets: - **macOS** - `.dmg`, `.app` - **Windows** - `.msi`, `.exe` +- **iOS** - TestFlight / App Store (via CI) +- **Android** - signed `.apk` (via CI, attached to GitHub Releases) + +### Mobile (iOS) + +The iOS app reuses the Rust backend in `src-tauri/` and renders the mobile frontend from `apps/mobile/`. iOS development requires macOS. + +**Prerequisites:** + +- **Xcode** + Command Line Tools, and **CocoaPods** (`brew install cocoapods`) +- Rust iOS targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim` +- An **Apple Developer Team ID** for code signing (find it under [Membership details](https://developer.apple.com/account#MembershipDetailsCard)) + +See the [Tauri iOS prerequisites](https://v2.tauri.app/start/prerequisites/) for the full list. + +Code signing is **not** committed to the repo — supply your own team ID via the `APPLE_DEVELOPMENT_TEAM` environment variable (it overrides `tauri.conf.json`'s `bundle.iOS.developmentTeam`). Export it in your shell profile so every `tauri ios` command picks it up: + +```bash +export APPLE_DEVELOPMENT_TEAM=XXXXXXXXXX # your Apple Developer Team ID +``` + +The Xcode project is generated and not committed (`src-tauri/gen/apple` is gitignored), so scaffold it once (with `APPLE_DEVELOPMENT_TEAM` set): + +```bash +yarn tauri ios init +``` + +Run the app in the iOS Simulator (or a connected device) with hot reload: + +```bash +yarn dev:ios +``` + +Build for production: + +```bash +yarn build:ios # default iOS build +yarn build:ios:appstore # App Store Connect (TestFlight / App Store) +yarn build:ios:adhoc # ad-hoc distribution +yarn build:android:apk # Android APK +yarn build:android:aab # Android App Bundle +``` + +For signed release builds via CI, see [`.github/MOBILE_DISTRIBUTION.md`](.github/MOBILE_DISTRIBUTION.md). + +The iOS scripts first generate `src-tauri/Info.ios.plist` (adds background-audio mode for preview playback, and the OAuth callback URL scheme for cloud sign-in). The scheme is derived from `ios_oauth_client_id` in `src-tauri/cloud_sync.config.json` (see `cloud_sync.config.example.json`); without it the app still builds, but cloud sign-in is unavailable. ## 🔗 Links diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 00000000..f120236b --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,36 @@ +{ + "name": "@bbx-audio/crate-desktop", + "version": "0.0.0", + "description": "Crate desktop app (Tauri + SvelteKit frontend)", + "type": "module", + "private": true, + "scripts": { + "dev:vite": "vite dev", + "build:vite": "vite build", + "preview": "vite preview", + "check:svelte": "svelte-kit sync && svelte-check --tsconfig tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig tsconfig.json --watch" + }, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@tanstack/virtual-core": "3.13.21", + "@tauri-apps/api": "2.11.0", + "@tauri-apps/plugin-clipboard-manager": "2.3.2", + "@tauri-apps/plugin-dialog": "2.7.1", + "@tauri-apps/plugin-fs": "2.5.1", + "@tauri-apps/plugin-opener": "2.5.3", + "@tauri-apps/plugin-process": "2.3.1", + "@tauri-apps/plugin-updater": "2.10.0", + "svelte-i18n": "4.0.1" + }, + "devDependencies": { + "@sveltejs/adapter-static": "3.0.10", + "@sveltejs/kit": "2.49.2", + "@sveltejs/vite-plugin-svelte": "6.2.1", + "@tailwindcss/vite": "4.1.18", + "svelte": "5.46.0", + "svelte-check": "4.3.4", + "tailwindcss": "4.1.18", + "vite": "7.3.0" + } +} diff --git a/src/app.html b/apps/desktop/src/app.html similarity index 100% rename from src/app.html rename to apps/desktop/src/app.html diff --git a/src/hooks.client.ts b/apps/desktop/src/hooks.client.ts similarity index 100% rename from src/hooks.client.ts rename to apps/desktop/src/hooks.client.ts diff --git a/src/lib/components/cloud-sync/Avatar.svelte b/apps/desktop/src/lib/components/cloud-sync/Avatar.svelte similarity index 100% rename from src/lib/components/cloud-sync/Avatar.svelte rename to apps/desktop/src/lib/components/cloud-sync/Avatar.svelte diff --git a/src/lib/components/cloud-sync/LibraryRootsWizard.svelte b/apps/desktop/src/lib/components/cloud-sync/LibraryRootsWizard.svelte similarity index 92% rename from src/lib/components/cloud-sync/LibraryRootsWizard.svelte rename to apps/desktop/src/lib/components/cloud-sync/LibraryRootsWizard.svelte index 1c70205f..3b2729b2 100644 --- a/src/lib/components/cloud-sync/LibraryRootsWizard.svelte +++ b/apps/desktop/src/lib/components/cloud-sync/LibraryRootsWizard.svelte @@ -1,8 +1,8 @@ diff --git a/src/lib/components/common/Tooltip.svelte b/apps/desktop/src/lib/components/common/Tooltip.svelte similarity index 100% rename from src/lib/components/common/Tooltip.svelte rename to apps/desktop/src/lib/components/common/Tooltip.svelte diff --git a/src/lib/components/common/UpdateModal.svelte b/apps/desktop/src/lib/components/common/UpdateModal.svelte similarity index 98% rename from src/lib/components/common/UpdateModal.svelte rename to apps/desktop/src/lib/components/common/UpdateModal.svelte index 7cb185b0..fdbd119b 100644 --- a/src/lib/components/common/UpdateModal.svelte +++ b/apps/desktop/src/lib/components/common/UpdateModal.svelte @@ -1,6 +1,6 @@ diff --git a/src/lib/components/editor/index.ts b/apps/desktop/src/lib/components/editor/index.ts similarity index 100% rename from src/lib/components/editor/index.ts rename to apps/desktop/src/lib/components/editor/index.ts diff --git a/src/lib/components/export/ExportFailureModal.svelte b/apps/desktop/src/lib/components/export/ExportFailureModal.svelte similarity index 94% rename from src/lib/components/export/ExportFailureModal.svelte rename to apps/desktop/src/lib/components/export/ExportFailureModal.svelte index b42558ed..b44c7b9f 100644 --- a/src/lib/components/export/ExportFailureModal.svelte +++ b/apps/desktop/src/lib/components/export/ExportFailureModal.svelte @@ -2,9 +2,9 @@ import Modal from '$lib/components/common/Modal.svelte' import Button from '$lib/components/common/Button.svelte' import Text from '$lib/components/common/Text.svelte' - import { translate } from '$lib/i18n' - import { getPendingCheckpoint } from '$lib/api/export' - import type { ExportCheckpoint } from '$lib/types' + import { translate } from '$shared/i18n' + import { getPendingCheckpoint } from '$shared/api/export' + import type { ExportCheckpoint } from '$shared/types' type Props = { open: boolean diff --git a/src/lib/components/export/ExportModal.svelte b/apps/desktop/src/lib/components/export/ExportModal.svelte similarity index 97% rename from src/lib/components/export/ExportModal.svelte rename to apps/desktop/src/lib/components/export/ExportModal.svelte index 1b744865..e17f536a 100644 --- a/src/lib/components/export/ExportModal.svelte +++ b/apps/desktop/src/lib/components/export/ExportModal.svelte @@ -4,11 +4,11 @@ import Checkbox from '$lib/components/common/Checkbox.svelte' import Text from '$lib/components/common/Text.svelte' import { SelectablePlaylistTree } from '$lib/components/playlists' - import { translate } from '$lib/i18n' - import { formatBytes } from '$lib/utils' - import type { Playlist, UsbDevice, ExportRequest } from '$lib/types' + import { translate } from '$shared/i18n' + import { formatBytes } from '$shared/utils' + import type { Playlist, UsbDevice, ExportRequest } from '$shared/types' import { SvelteSet } from 'svelte/reactivity' - import { exportFormat } from '$lib/stores/settings' + import { exportFormat } from '$shared/stores/settings' type Props = { open: boolean diff --git a/src/lib/components/export/QuickExportModal.svelte b/apps/desktop/src/lib/components/export/QuickExportModal.svelte similarity index 97% rename from src/lib/components/export/QuickExportModal.svelte rename to apps/desktop/src/lib/components/export/QuickExportModal.svelte index cc0bc036..dee41d79 100644 --- a/src/lib/components/export/QuickExportModal.svelte +++ b/apps/desktop/src/lib/components/export/QuickExportModal.svelte @@ -5,10 +5,10 @@ import Text from '$lib/components/common/Text.svelte' import { SelectablePlaylistTree } from '$lib/components/playlists' import { DeviceItem } from '$lib/components/devices' - import { translate } from '$lib/i18n' + import { translate } from '$shared/i18n' import { activeDeviceId } from '$lib/stores/export' - import { exportFormat } from '$lib/stores/settings' - import type { Playlist, UsbDevice, ExportRequest } from '$lib/types' + import { exportFormat } from '$shared/stores/settings' + import type { Playlist, UsbDevice, ExportRequest } from '$shared/types' import { SvelteSet } from 'svelte/reactivity' type Props = { diff --git a/src/lib/components/export/index.ts b/apps/desktop/src/lib/components/export/index.ts similarity index 100% rename from src/lib/components/export/index.ts rename to apps/desktop/src/lib/components/export/index.ts diff --git a/src/lib/components/follow/FollowPopover.svelte b/apps/desktop/src/lib/components/follow/FollowPopover.svelte similarity index 97% rename from src/lib/components/follow/FollowPopover.svelte rename to apps/desktop/src/lib/components/follow/FollowPopover.svelte index ac46431f..487aca5a 100644 --- a/src/lib/components/follow/FollowPopover.svelte +++ b/apps/desktop/src/lib/components/follow/FollowPopover.svelte @@ -7,12 +7,12 @@
diff --git a/src/lib/components/onboarding/OnboardingWizard.svelte b/apps/desktop/src/lib/components/onboarding/OnboardingWizard.svelte similarity index 98% rename from src/lib/components/onboarding/OnboardingWizard.svelte rename to apps/desktop/src/lib/components/onboarding/OnboardingWizard.svelte index b5707367..b6cafb53 100644 --- a/src/lib/components/onboarding/OnboardingWizard.svelte +++ b/apps/desktop/src/lib/components/onboarding/OnboardingWizard.svelte @@ -2,7 +2,7 @@ import { fly, fade } from 'svelte/transition' import { cubicOut } from 'svelte/easing' import { Button, StepIndicator } from '$lib/components/common' - import { translate } from '$lib/i18n' + import { translate } from '$shared/i18n' import WelcomeStep from './WelcomeStep.svelte' import LanguageStep from './LanguageStep.svelte' import AppearanceStep from './AppearanceStep.svelte' diff --git a/src/lib/components/onboarding/ReadyStep.svelte b/apps/desktop/src/lib/components/onboarding/ReadyStep.svelte similarity index 93% rename from src/lib/components/onboarding/ReadyStep.svelte rename to apps/desktop/src/lib/components/onboarding/ReadyStep.svelte index 41ccbbcc..cd30f68d 100644 --- a/src/lib/components/onboarding/ReadyStep.svelte +++ b/apps/desktop/src/lib/components/onboarding/ReadyStep.svelte @@ -1,7 +1,7 @@
diff --git a/src/lib/components/onboarding/WelcomeStep.svelte b/apps/desktop/src/lib/components/onboarding/WelcomeStep.svelte similarity index 94% rename from src/lib/components/onboarding/WelcomeStep.svelte rename to apps/desktop/src/lib/components/onboarding/WelcomeStep.svelte index 40cebb40..c7599ec2 100644 --- a/src/lib/components/onboarding/WelcomeStep.svelte +++ b/apps/desktop/src/lib/components/onboarding/WelcomeStep.svelte @@ -1,6 +1,6 @@
diff --git a/src/lib/components/onboarding/index.ts b/apps/desktop/src/lib/components/onboarding/index.ts similarity index 100% rename from src/lib/components/onboarding/index.ts rename to apps/desktop/src/lib/components/onboarding/index.ts diff --git a/src/lib/components/player/PlaybackControls.svelte b/apps/desktop/src/lib/components/player/PlaybackControls.svelte similarity index 97% rename from src/lib/components/player/PlaybackControls.svelte rename to apps/desktop/src/lib/components/player/PlaybackControls.svelte index 07cd475b..c8d7dc35 100644 --- a/src/lib/components/player/PlaybackControls.svelte +++ b/apps/desktop/src/lib/components/player/PlaybackControls.svelte @@ -1,6 +1,6 @@ + + + Crate + %sveltekit.head% + + + +
+
+ Crate + v%sveltekit.env.PUBLIC_APP_VERSION% +
+ +
%sveltekit.body%
+ + diff --git a/apps/mobile/src/lib/actions/swipe.ts b/apps/mobile/src/lib/actions/swipe.ts new file mode 100644 index 00000000..33d7e904 --- /dev/null +++ b/apps/mobile/src/lib/actions/swipe.ts @@ -0,0 +1,228 @@ +import type { Action } from 'svelte/action' +import { DRAG_THRESHOLD } from '$shared/utils/drag' + +/** + * Reusable pointer-based swipe action for the mobile drawers — no gesture library, matching the + * codebase's existing pointer-event conventions (see PlaylistItem.svelte / shared/utils/drag.ts). + * + * It supports two modes: + * - `mode: 'open'` — an edge gesture: the drag must start within `edgeSize` px of the screen edge + * and moves the drawer from closed (0) toward open (1). + * - `mode: 'close'` — a panel gesture: the drag starts anywhere on the node and moves the drawer + * from open (1) toward closed (0). + * + * During a claimed drag it emits `onProgress(openness)` (0 = closed, 1 = open) for finger-follow, and + * on release resolves to `onOpen()` / `onClose()` using a distance threshold or a velocity flick. + * + * Axis-lock: the gesture is only claimed once horizontal intent exceeds vertical, so vertical + * scrolling of the underlying list is never hijacked. `touch-action: pan-y` is set on the node so the + * browser keeps handling vertical scroll while reserving horizontal movement for this action. + */ + +export type SwipeSide = 'left' | 'right' + +export interface SwipeOptions { + /** Which side the drawer is anchored to. */ + side: SwipeSide + /** 'open' = edge-to-open gesture; 'close' = drag-the-panel-closed gesture. */ + mode: 'open' | 'close' + /** Live openness 0→1 during a drag (translate the drawer to follow the finger). */ + onProgress?: (openness: number) => void + /** Gesture released committing to open. */ + onOpen?: () => void + /** Gesture released committing to closed. */ + onClose?: () => void + /** Drawer width in px; defaults to the node's measured width (falls back to viewport width). */ + width?: number + /** Hit zone from the screen edge for `mode: 'open'`, in px. Default 24. */ + edgeSize?: number + /** For `mode: 'close'`, restrict the drag to start within this many px of an edge (iOS-style + * interactive back-swipe). When unset, a close drag can start anywhere on the node. */ + closeEdgeSize?: number + /** Which screen edge `closeEdgeSize` is measured from (defaults to `side`). A panel that slides + * off to the right (`side: 'right'`) but is dismissed by an edge-swipe from the LEFT sets + * `closeEdgeFrom: 'left'`. */ + closeEdgeFrom?: SwipeSide + /** Fraction of width that commits the gesture on release. Default 0.4. */ + snapFraction?: number + /** Disable the gesture (e.g. when the drawer isn't in the relevant open/closed state). Default true. */ + enabled?: boolean +} + +interface ResolvedOptions { + side: SwipeSide + mode: 'open' | 'close' + onProgress?: (openness: number) => void + onOpen?: () => void + onClose?: () => void + width?: number + edgeSize: number + closeEdgeSize?: number + closeEdgeFrom?: SwipeSide + snapFraction: number + enabled: boolean +} + +const FLICK_VELOCITY = 0.4 // px/ms — above this, direction of travel wins regardless of distance + +function resolve(opts: SwipeOptions): ResolvedOptions { + return { + side: opts.side, + mode: opts.mode, + onProgress: opts.onProgress, + onOpen: opts.onOpen, + onClose: opts.onClose, + width: opts.width, + edgeSize: opts.edgeSize ?? 24, + closeEdgeSize: opts.closeEdgeSize, + closeEdgeFrom: opts.closeEdgeFrom, + snapFraction: opts.snapFraction ?? 0.4, + enabled: opts.enabled ?? true, + } +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +export const swipe: Action = (node, initial) => { + let opts = resolve(initial) + + let pointerId: number | null = null + let startX = 0 + let startY = 0 + let lastX = 0 + let lastT = 0 + let velocity = 0 + let claimed = false + let abandoned = false + + // Sign of the "opening" direction along x: left drawer opens with +dx, right drawer with -dx. + const dir = () => (opts.side === 'left' ? 1 : -1) + // Openness at the start of the gesture: opening starts closed (0), closing starts open (1). + const base = () => (opts.mode === 'open' ? 0 : 1) + const width = () => { + if (opts.width) return opts.width + return node.getBoundingClientRect().width || window.innerWidth + } + + function opennessFor(dx: number): number { + return clamp(base() + (dir() * dx) / width(), 0, 1) + } + + function onPointerDown(e: PointerEvent) { + if (!opts.enabled || pointerId !== null) return + if (e.pointerType === 'mouse' && e.button !== 0) return + + // Edge gate: 'open' always starts from the screen edge; 'close' is edge-gated only when + // `closeEdgeSize` is set (iOS-style back-swipe). The edge defaults to the anchor `side` but can + // be overridden via `closeEdgeFrom` (e.g. a right-exiting panel dismissed from the left edge). + const edgeLimit = opts.mode === 'open' ? opts.edgeSize : opts.closeEdgeSize + if (edgeLimit != null) { + const edgeSide = opts.mode === 'open' ? opts.side : (opts.closeEdgeFrom ?? opts.side) + const fromEdge = edgeSide === 'left' ? e.clientX : window.innerWidth - e.clientX + if (fromEdge > edgeLimit) return + } + + pointerId = e.pointerId + startX = lastX = e.clientX + startY = e.clientY + lastT = e.timeStamp + velocity = 0 + claimed = false + abandoned = false + + window.addEventListener('pointermove', onPointerMove, { passive: false }) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerUp) + } + + function onPointerMove(e: PointerEvent) { + if (e.pointerId !== pointerId || abandoned) return + + const dx = e.clientX - startX + const dy = e.clientY - startY + + if (!claimed) { + if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return + + // Vertical intent → release to the scroll container. + if (Math.abs(dy) > Math.abs(dx)) { + abandoned = true + teardownWindow() + pointerId = null + return + } + + // Horizontal, but in the wrong direction (e.g. trying to "open" past fully-open) → ignore. + const wantsForward = opts.mode === 'open' ? dir() * dx > 0 : dir() * dx < 0 + if (!wantsForward) { + abandoned = true + teardownWindow() + pointerId = null + return + } + + claimed = true + } + + if (e.cancelable) e.preventDefault() + + const now = e.timeStamp + if (now > lastT) velocity = (e.clientX - lastX) / (now - lastT) + lastX = e.clientX + lastT = now + + opts.onProgress?.(opennessFor(dx)) + } + + function onPointerUp(e: PointerEvent) { + if (e.pointerId !== pointerId) return + + const dx = e.clientX - startX + const openness = opennessFor(dx) + const flick = dir() * velocity // > 0 means travelling in the opening direction + const wasClaimed = claimed + + teardownWindow() + pointerId = null + + if (!wasClaimed) return + + let open: boolean + if (Math.abs(flick) > FLICK_VELOCITY) { + open = flick > 0 + } else if (opts.mode === 'open') { + open = openness >= opts.snapFraction + } else { + open = openness > 1 - opts.snapFraction + } + + if (open) opts.onOpen?.() + else opts.onClose?.() + } + + function teardownWindow() { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerUp) + } + + function applyTouchAction() { + node.style.touchAction = opts.enabled ? 'pan-y' : '' + } + + node.addEventListener('pointerdown', onPointerDown) + applyTouchAction() + + return { + update(next: SwipeOptions) { + opts = resolve(next) + applyTouchAction() + }, + destroy() { + node.removeEventListener('pointerdown', onPointerDown) + teardownWindow() + }, + } +} diff --git a/apps/mobile/src/lib/actions/swipePager.ts b/apps/mobile/src/lib/actions/swipePager.ts new file mode 100644 index 00000000..bfd2627c --- /dev/null +++ b/apps/mobile/src/lib/actions/swipePager.ts @@ -0,0 +1,169 @@ +import type { Action } from 'svelte/action' +import { DRAG_THRESHOLD } from '$shared/utils/drag' + +/** + * Horizontal pointer-based paging swipe for the expanded player's cover strip — the bidirectional + * (±dx) sibling of `swipe.ts`/`swipeVertical.ts`. Same pointer-event conventions and axis-lock + * approach: the gesture is only claimed once horizontal intent exceeds vertical, so the player + * sheet's drag-to-dismiss (which claims the complementary case, ties included) is never hijacked, + * and a stationary tap is never claimed — child taps still fire normally. + * + * Unlike the drawer swipes this doesn't model 0→1 openness: it reports a live signed `dx` + * (finger-follow), rubber-bands when paging in a direction isn't possible, and on release commits + * to a page (`dir: 1` = next, leftward drag; `-1` = previous) by distance or flick, else cancels. + */ + +export interface SwipePagerOptions { + /** Claimed drag began (past the axis-locked threshold). Snapshot pager state here. */ + onDragStart?: () => void + /** Live signed horizontal offset in px during a claimed drag; edge resistance already applied. */ + onDrag?: (dx: number) => void + /** Released committing to a page: `dir` 1 = next (dragged left), -1 = previous (dragged right). */ + onCommit?: (dir: 1 | -1, releaseDx: number, velocity: number) => void + /** Released without committing (didn't cross the threshold, or paging that way isn't possible). */ + onCancel?: () => void + /** Whether a page exists in a direction. False → that direction rubber-bands and never commits. */ + canPage?: (dir: 1 | -1) => boolean + /** Page width in px used for the commit fraction and resistance curve. Defaults to node width. */ + width?: () => number + /** Fraction of the width that commits on release. Default 0.35. */ + commitFraction?: number + /** Disable the gesture. Default true. */ + enabled?: boolean +} + +const FLICK_VELOCITY = 0.4 // px/ms — above this, direction of travel wins regardless of distance + +// Asymptotic rubber-band for a blocked direction: approaches (but never reaches) half a page of +// travel, so the card visibly resists instead of following the finger. +function resist(dx: number, width: number): number { + return width * (1 - 1 / (1 + Math.abs(dx) / (width * 2))) * Math.sign(dx) +} + +export const swipePager: Action = (node, initial) => { + let opts = initial ?? {} + + let pointerId: number | null = null + let startX = 0 + let startY = 0 + let lastX = 0 + let lastT = 0 + let velocity = 0 + let claimed = false + let abandoned = false + + function pageWidth(): number { + return opts.width?.() ?? node.clientWidth ?? 0 + } + + function reportDx(rawDx: number): number { + const dir: 1 | -1 = rawDx < 0 ? 1 : -1 + const blocked = opts.canPage ? !opts.canPage(dir) : false + return blocked ? resist(rawDx, Math.max(1, pageWidth())) : rawDx + } + + function onPointerDown(e: PointerEvent) { + if (opts.enabled === false || pointerId !== null) return + if (e.pointerType === 'mouse' && e.button !== 0) return + + pointerId = e.pointerId + startX = lastX = e.clientX + startY = e.clientY + lastT = e.timeStamp + velocity = 0 + claimed = false + abandoned = false + + window.addEventListener('pointermove', onPointerMove, { passive: false }) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerCancel) + } + + function onPointerMove(e: PointerEvent) { + if (e.pointerId !== pointerId || abandoned) return + + const dx = e.clientX - startX + const dy = e.clientY - startY + + if (!claimed) { + if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return + + // Vertical intent (ties included) → release to the sheet's drag-to-dismiss. + if (Math.abs(dy) >= Math.abs(dx)) { + abandoned = true + teardownWindow() + pointerId = null + return + } + + claimed = true + opts.onDragStart?.() + } + + if (e.cancelable) e.preventDefault() + + const now = e.timeStamp + if (now > lastT) velocity = (e.clientX - lastX) / (now - lastT) + lastX = e.clientX + lastT = now + + opts.onDrag?.(reportDx(dx)) + } + + function onPointerUp(e: PointerEvent) { + if (e.pointerId !== pointerId) return + + const dx = e.clientX - startX + const wasClaimed = claimed + + teardownWindow() + pointerId = null + + if (!wasClaimed) return + + const dir: 1 | -1 = dx < 0 ? 1 : -1 + const width = Math.max(1, pageWidth()) + const pageable = opts.canPage ? opts.canPage(dir) : true + const byDistance = Math.abs(dx) > (opts.commitFraction ?? 0.35) * width + // A flick only commits when it travels the same way as the drag (no reverse-flick commits). + const byFlick = Math.abs(velocity) > FLICK_VELOCITY && Math.sign(velocity) === Math.sign(dx) + + if (pageable && dx !== 0 && (byDistance || byFlick)) opts.onCommit?.(dir, dx, velocity) + else opts.onCancel?.() + } + + function onPointerCancel(e: PointerEvent) { + if (e.pointerId !== pointerId) return + const wasClaimed = claimed + teardownWindow() + pointerId = null + if (wasClaimed) opts.onCancel?.() + } + + function teardownWindow() { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + } + + function applyTouchAction() { + // The strip contains no scrollable content, so claim all native panning: vertical drags stay + // cancelable pointer events for the sheet's window-level dismiss handler (JS axis-lock + // arbitrates between the two), and horizontal ones are ours. + node.style.touchAction = opts.enabled === false ? '' : 'none' + } + + node.addEventListener('pointerdown', onPointerDown) + applyTouchAction() + + return { + update(next: SwipePagerOptions) { + opts = next ?? {} + applyTouchAction() + }, + destroy() { + node.removeEventListener('pointerdown', onPointerDown) + teardownWindow() + }, + } +} diff --git a/apps/mobile/src/lib/actions/swipeVertical.ts b/apps/mobile/src/lib/actions/swipeVertical.ts new file mode 100644 index 00000000..7dcf10e2 --- /dev/null +++ b/apps/mobile/src/lib/actions/swipeVertical.ts @@ -0,0 +1,155 @@ +import type { Action } from 'svelte/action' +import { DRAG_THRESHOLD } from '$shared/utils/drag' + +/** + * Reusable pointer-based vertical swipe action for the preview player — the vertical sibling of + * `swipe.ts` (which handles the horizontal drawer gestures). No gesture library; same pointer-event + * conventions and axis-lock approach. + * + * The mini-player uses `onSwipeUp` to expand; the expanded player uses `onProgress` (finger-follow + * translate) + `onSwipeDown` to dismiss. The gesture is only claimed once vertical intent exceeds + * horizontal, so a horizontal drawer swipe started on the bar is never hijacked, and a stationary tap + * (no movement past the threshold) is never claimed — letting child button `onclick`s fire normally. + */ + +export interface SwipeVerticalOptions { + /** Released committing to an upward swipe (distance or flick). */ + onSwipeUp?: () => void + /** Released committing to a downward swipe (distance or flick). */ + onSwipeDown?: () => void + /** Live vertical offset in px during a claimed drag (negative = up, positive = down); 0 on release. */ + onProgress?: (dy: number) => void + /** Distance in px that commits the gesture on release. Default 56. */ + threshold?: number + /** Disable the gesture. Default true. */ + enabled?: boolean +} + +interface ResolvedOptions { + onSwipeUp?: () => void + onSwipeDown?: () => void + onProgress?: (dy: number) => void + threshold: number + enabled: boolean +} + +const FLICK_VELOCITY = 0.4 // px/ms — above this, direction of travel wins regardless of distance + +function resolve(opts: SwipeVerticalOptions): ResolvedOptions { + return { + onSwipeUp: opts.onSwipeUp, + onSwipeDown: opts.onSwipeDown, + onProgress: opts.onProgress, + threshold: opts.threshold ?? 56, + enabled: opts.enabled ?? true, + } +} + +export const swipeVertical: Action = (node, initial) => { + let opts = resolve(initial) + + let pointerId: number | null = null + let startX = 0 + let startY = 0 + let lastY = 0 + let lastT = 0 + let velocity = 0 + let claimed = false + let abandoned = false + + function onPointerDown(e: PointerEvent) { + if (!opts.enabled || pointerId !== null) return + if (e.pointerType === 'mouse' && e.button !== 0) return + + pointerId = e.pointerId + startX = e.clientX + startY = lastY = e.clientY + lastT = e.timeStamp + velocity = 0 + claimed = false + abandoned = false + + window.addEventListener('pointermove', onPointerMove, { passive: false }) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerUp) + } + + function onPointerMove(e: PointerEvent) { + if (e.pointerId !== pointerId || abandoned) return + + const dx = e.clientX - startX + const dy = e.clientY - startY + + if (!claimed) { + if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return + + // Horizontal intent → release to the drawer gesture / underlying content. + if (Math.abs(dx) > Math.abs(dy)) { + abandoned = true + teardownWindow() + pointerId = null + return + } + + claimed = true + } + + if (e.cancelable) e.preventDefault() + + const now = e.timeStamp + if (now > lastT) velocity = (e.clientY - lastY) / (now - lastT) + lastY = e.clientY + lastT = now + + opts.onProgress?.(dy) + } + + function onPointerUp(e: PointerEvent) { + if (e.pointerId !== pointerId) return + + const dy = e.clientY - startY + const wasClaimed = claimed + + teardownWindow() + pointerId = null + + if (!wasClaimed) return + + opts.onProgress?.(0) + + let direction: 'up' | 'down' | null = null + if (Math.abs(velocity) > FLICK_VELOCITY) { + direction = velocity < 0 ? 'up' : 'down' + } else if (Math.abs(dy) > opts.threshold) { + direction = dy < 0 ? 'up' : 'down' + } + + if (direction === 'up') opts.onSwipeUp?.() + else if (direction === 'down') opts.onSwipeDown?.() + } + + function teardownWindow() { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerUp) + } + + function applyTouchAction() { + // Let the browser keep horizontal panning; we own vertical movement on this node. + node.style.touchAction = opts.enabled ? 'pan-x' : '' + } + + node.addEventListener('pointerdown', onPointerDown) + applyTouchAction() + + return { + update(next: SwipeVerticalOptions) { + opts = resolve(next) + applyTouchAction() + }, + destroy() { + node.removeEventListener('pointerdown', onPointerDown) + teardownWindow() + }, + } +} diff --git a/apps/mobile/src/lib/cloudSyncMerge.ts b/apps/mobile/src/lib/cloudSyncMerge.ts new file mode 100644 index 00000000..65c5aff7 --- /dev/null +++ b/apps/mobile/src/lib/cloudSyncMerge.ts @@ -0,0 +1,103 @@ +import { listen, type UnlistenFn } from '@tauri-apps/api/event' +import { discoveryStore } from '$shared/stores/discovery' +import { tagsStore } from '$shared/stores/tags' +import { playlistsStore } from '$shared/stores/playlists' +import { settingsStore } from '$shared/stores/settings' + +const BUCKET_FLAGS = { + discovery_releases: 'discovery', + discovery_tracks: 'discovery', + discovery_release_tags: 'discovery,tags', + discovery_release_sources: 'discovery', + playlist_discovery_releases: 'discovery,playlists', + tags: 'tags,discovery', + tag_categories: 'tags,discovery', + playlists: 'playlists', + playlist_tracks: 'playlists', + settings: 'settings', +} as const + +// Reloading every touched store per `cloud-sync-merged` event re-fetches the release list +// (thousands of rows with nested tracks/tags) once per sync cycle — and back-to-back cycles +// (foreground returns, background refresh) would repeat it. Coalesce: accumulate flags and +// flush on a trailing debounce with a max-wait ceiling. +const RELOAD_DEBOUNCE_MS = 750 +const RELOAD_MAX_WAIT_MS = 4000 + +// User actions take priority over sync-triggered reloads: never start a heavy store reload +// while the user is actively touching the screen — wait for a quiet gap, up to a cap so the +// reload can't be starved forever by continuous scrolling. +const INTERACTION_QUIET_MS = 500 +const INTERACTION_DEFER_CAP_MS = 8000 + +const pendingFlags = new Set() +let debounceTimer: ReturnType | null = null +let maxWaitTimer: ReturnType | null = null +let lastInteractionAt = 0 +let flushDeferredSince = 0 + +function bumpInteraction(): void { + lastInteractionAt = Date.now() +} + +function flushPendingReloads(): void { + // Defer while the user is mid-interaction (bounded): reschedule and check again after + // the quiet window. The timers are left as-is here — this reschedule IS the new timer. + const now = Date.now() + if (now - lastInteractionAt < INTERACTION_QUIET_MS) { + if (!flushDeferredSince) flushDeferredSince = now + if (now - flushDeferredSince < INTERACTION_DEFER_CAP_MS) { + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(flushPendingReloads, INTERACTION_QUIET_MS) + return + } + } + flushDeferredSince = 0 + + if (debounceTimer) clearTimeout(debounceTimer) + if (maxWaitTimer) clearTimeout(maxWaitTimer) + debounceTimer = null + maxWaitTimer = null + + if (pendingFlags.has('discovery')) discoveryStore.loadReleases() + if (pendingFlags.has('playlists')) playlistsStore.load() + if (pendingFlags.has('tags')) tagsStore.load() + if (pendingFlags.has('settings')) settingsStore.load() + pendingFlags.clear() +} + +function scheduleReloadForBuckets(buckets: string[]): void { + for (const bucket of buckets) { + const flags = BUCKET_FLAGS[bucket as keyof typeof BUCKET_FLAGS] + if (!flags) continue + for (const flag of flags.split(',')) pendingFlags.add(flag) + } + if (pendingFlags.size === 0) return + + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(flushPendingReloads, RELOAD_DEBOUNCE_MS) + if (!maxWaitTimer) { + maxWaitTimer = setTimeout(flushPendingReloads, RELOAD_MAX_WAIT_MS) + } +} + +export async function setupCloudSyncMergeListener(): Promise { + window.addEventListener('touchstart', bumpInteraction, { passive: true, capture: true }) + window.addEventListener('touchmove', bumpInteraction, { passive: true, capture: true }) + window.addEventListener('pointerdown', bumpInteraction, { passive: true, capture: true }) + + const unlisten = await listen('cloud-sync-merged', (event) => { + scheduleReloadForBuckets(event.payload) + }) + return () => { + window.removeEventListener('touchstart', bumpInteraction, { capture: true }) + window.removeEventListener('touchmove', bumpInteraction, { capture: true }) + window.removeEventListener('pointerdown', bumpInteraction, { capture: true }) + if (debounceTimer) clearTimeout(debounceTimer) + if (maxWaitTimer) clearTimeout(maxWaitTimer) + debounceTimer = null + maxWaitTimer = null + pendingFlags.clear() + unlisten() + } +} diff --git a/apps/mobile/src/lib/components/cloud-sync/SyncPanel.svelte b/apps/mobile/src/lib/components/cloud-sync/SyncPanel.svelte new file mode 100644 index 00000000..9039a3ef --- /dev/null +++ b/apps/mobile/src/lib/components/cloud-sync/SyncPanel.svelte @@ -0,0 +1,203 @@ + + +{#if $isSignedIn} +
+ +
+ + + {#if showPhoto} + (photoError = true)} + /> + {:else} + {initials || '?'} + {/if} + + + +
+ {#if $syncStatus.display_name} +

{$syncStatus.display_name}

+ {/if} + {#if $syncStatus.email} +

{$syncStatus.email}

+ {/if} +
+
+ + +
+ +
+

{statusLabel}

+ {#if lastSynced} +

{lastSynced}

+ {/if} +
+
+ + +

{$translate('cloudSync.account.autoSyncHint')}

+ + +
+ + +
+
+{:else} +
+

{$translate('cloudSync.signIn.title')}

+ + + {#if $cloudSyncError} +

{$cloudSyncError}

+ {/if} +
+{/if} diff --git a/apps/mobile/src/lib/components/common/ContextMenu.svelte b/apps/mobile/src/lib/components/common/ContextMenu.svelte new file mode 100644 index 00000000..d1824bbb --- /dev/null +++ b/apps/mobile/src/lib/components/common/ContextMenu.svelte @@ -0,0 +1,303 @@ + + +{#if visible} + + + + {#if anchorRect && preview} + + {/if} + + +{/if} diff --git a/apps/mobile/src/lib/components/common/ContextMenuItem.svelte b/apps/mobile/src/lib/components/common/ContextMenuItem.svelte new file mode 100644 index 00000000..8c61aefd --- /dev/null +++ b/apps/mobile/src/lib/components/common/ContextMenuItem.svelte @@ -0,0 +1,42 @@ + + +{#if separatorBefore} +
+{/if} + + diff --git a/apps/mobile/src/lib/components/common/Drawer.svelte b/apps/mobile/src/lib/components/common/Drawer.svelte new file mode 100644 index 00000000..6be911a8 --- /dev/null +++ b/apps/mobile/src/lib/components/common/Drawer.svelte @@ -0,0 +1,342 @@ + + +{#if scrim && visible} + {#if scrimDismiss} + + {:else} +
+ {/if} +{/if} + +{#if visible} + +{/if} diff --git a/apps/mobile/src/lib/components/common/EmptyState.svelte b/apps/mobile/src/lib/components/common/EmptyState.svelte new file mode 100644 index 00000000..974cef69 --- /dev/null +++ b/apps/mobile/src/lib/components/common/EmptyState.svelte @@ -0,0 +1,35 @@ + + +
+ {#if icon} + {@render icon()} + {/if} +
{title}
+ {#if hint} +
{hint}
+ {/if} + {#if ctaLabel && onCta} + + {/if} +
diff --git a/apps/mobile/src/lib/components/common/EqualizerBars.svelte b/apps/mobile/src/lib/components/common/EqualizerBars.svelte new file mode 100644 index 00000000..f2ea7424 --- /dev/null +++ b/apps/mobile/src/lib/components/common/EqualizerBars.svelte @@ -0,0 +1,71 @@ + + + + + diff --git a/apps/mobile/src/lib/components/common/MarqueeText.svelte b/apps/mobile/src/lib/components/common/MarqueeText.svelte new file mode 100644 index 00000000..70f55f21 --- /dev/null +++ b/apps/mobile/src/lib/components/common/MarqueeText.svelte @@ -0,0 +1,107 @@ + + +
+ {#if shouldAnimate} +
+ {text} + +
+ {:else} + {text} + {/if} +
+ + diff --git a/apps/mobile/src/lib/components/common/MobileList.svelte b/apps/mobile/src/lib/components/common/MobileList.svelte new file mode 100644 index 00000000..7f9b6106 --- /dev/null +++ b/apps/mobile/src/lib/components/common/MobileList.svelte @@ -0,0 +1,31 @@ + + +
+ {#if title} +

+ {title} +

+ {/if} + + {#if isEmpty && empty} +
{@render empty()}
+ {:else} +
+ {@render children()} +
+ {/if} +
diff --git a/apps/mobile/src/lib/components/common/MobileListItem.svelte b/apps/mobile/src/lib/components/common/MobileListItem.svelte new file mode 100644 index 00000000..aeddd803 --- /dev/null +++ b/apps/mobile/src/lib/components/common/MobileListItem.svelte @@ -0,0 +1,65 @@ + + +{#snippet inner()} + {#if leading} + {@render leading()} + {/if} + {@render children()} + {#if trailing} + {@render trailing()} + {/if} +{/snippet} + +{#if onclick} + +{:else} +
+ {@render inner()} +
+{/if} diff --git a/apps/mobile/src/lib/components/common/MobileListSkeleton.svelte b/apps/mobile/src/lib/components/common/MobileListSkeleton.svelte new file mode 100644 index 00000000..d3ec0559 --- /dev/null +++ b/apps/mobile/src/lib/components/common/MobileListSkeleton.svelte @@ -0,0 +1,33 @@ + + + diff --git a/apps/mobile/src/lib/components/common/MobileModal.svelte b/apps/mobile/src/lib/components/common/MobileModal.svelte new file mode 100644 index 00000000..4efe6209 --- /dev/null +++ b/apps/mobile/src/lib/components/common/MobileModal.svelte @@ -0,0 +1,76 @@ + + + + {#snippet children({ drag, animating })} + +
+
+ +
+ + {#if title} +
+

{title}

+ {#if headerAction}{@render headerAction()}{/if} +
+ {/if} +
+ +
+ {@render body()} +
+ + {#if footer} +
+ {@render footer()} +
+ {/if} + {/snippet} +
diff --git a/apps/mobile/src/lib/components/common/MobilePromptDialog.svelte b/apps/mobile/src/lib/components/common/MobilePromptDialog.svelte new file mode 100644 index 00000000..3f77eed0 --- /dev/null +++ b/apps/mobile/src/lib/components/common/MobilePromptDialog.svelte @@ -0,0 +1,148 @@ + + +{#if open} + + + + +
+ +
+{/if} diff --git a/apps/mobile/src/lib/components/common/MobileSearchInput.svelte b/apps/mobile/src/lib/components/common/MobileSearchInput.svelte new file mode 100644 index 00000000..bfd29700 --- /dev/null +++ b/apps/mobile/src/lib/components/common/MobileSearchInput.svelte @@ -0,0 +1,38 @@ + + +
+ + + + + oninput?.(e.currentTarget.value)} + {placeholder} + aria-label={ariaLabel ?? placeholder} + autocapitalize="off" + autocomplete="off" + autocorrect="off" + spellcheck="false" + class="h-9 w-full rounded-md border border-stroke bg-surface-2 pr-3 pl-8 text-sm text-text-primary transition-colors placeholder:text-text-tertiary focus:border-brand-primary focus:shadow-[0_0_0_3px_var(--brand-muted)]" + /> +
diff --git a/apps/mobile/src/lib/components/common/PullToRefresh.svelte b/apps/mobile/src/lib/components/common/PullToRefresh.svelte new file mode 100644 index 00000000..175e71dc --- /dev/null +++ b/apps/mobile/src/lib/components/common/PullToRefresh.svelte @@ -0,0 +1,168 @@ + + + +
+ +
diff --git a/apps/mobile/src/lib/components/common/ReleaseArtwork.svelte b/apps/mobile/src/lib/components/common/ReleaseArtwork.svelte new file mode 100644 index 00000000..1f7dc0cd --- /dev/null +++ b/apps/mobile/src/lib/components/common/ReleaseArtwork.svelte @@ -0,0 +1,57 @@ + + +{#if src} + +{:else if fallback} + {@render fallback()} +{/if} diff --git a/apps/mobile/src/lib/components/common/Spinner.svelte b/apps/mobile/src/lib/components/common/Spinner.svelte new file mode 100644 index 00000000..c7e54614 --- /dev/null +++ b/apps/mobile/src/lib/components/common/Spinner.svelte @@ -0,0 +1,25 @@ + + + diff --git a/apps/mobile/src/lib/components/common/SplashScreen.svelte b/apps/mobile/src/lib/components/common/SplashScreen.svelte new file mode 100644 index 00000000..987ed528 --- /dev/null +++ b/apps/mobile/src/lib/components/common/SplashScreen.svelte @@ -0,0 +1,51 @@ + + +{#if show} + +
+
+ Crate + v{version} +
+{/if} diff --git a/apps/mobile/src/lib/components/common/ToastContainer.svelte b/apps/mobile/src/lib/components/common/ToastContainer.svelte new file mode 100644 index 00000000..a3b1bde6 --- /dev/null +++ b/apps/mobile/src/lib/components/common/ToastContainer.svelte @@ -0,0 +1,98 @@ + + +{#if $toasts.length > 0} +
+ {#each $toasts as toast (toast.id)} + + {/each} +
+{/if} diff --git a/apps/mobile/src/lib/components/discovery/AddReleaseModal.svelte b/apps/mobile/src/lib/components/discovery/AddReleaseModal.svelte new file mode 100644 index 00000000..856d122a --- /dev/null +++ b/apps/mobile/src/lib/components/discovery/AddReleaseModal.svelte @@ -0,0 +1,465 @@ + + + + + {#snippet children({ drag, animating })} + +
+
+ +
+
+ +

{$translate('discovery.addRelease')}

+ {#if isBulkMode} + + {:else if isOffline && url.trim() && !unsupportedUrl} + + {:else} + + {/if} +
+
+ + +
+ {#if isBulkMode && scannedPage} + + {:else} +
+ +
+ + + {#if fetching} +
+ + {$translate('discovery.fetchingMetadata')} +
+ {:else if scanning} +
+ + + {#if scanProgress?.total_pages} + {$translate('discovery.scanningReleasesProgress', { + values: { + current: scanProgress.current_page, + total: scanProgress.total_pages, + found: scanProgress.releases_found, + }, + })} + {:else if scanProgress?.entity_name} + {$translate('discovery.scanningReleasesEntity', { values: { name: scanProgress.entity_name } })} + {:else} + {$translate('discovery.scanningReleases')} + {/if} + +
+ {:else if unsupportedUrl} +

{$translate('discovery.unsupportedUrl')}

+ {:else if isOffline} +

{$translate('discovery.offlineQueueNotice')}

+ {:else if fetchError} +

{$translate('discovery.fetchError')}

+ {/if} +
+ + + {#if matchFound} +
+

+ {$translate('discovery.similarFound', { values: { title: title || '', artist: artist || '' } })} +

+
+ {/if} + + + {#if artworkPreview} +
+ +
+ {/if} + + + {#if sourceType !== 'other' && !fetchedData} +
+ + {sourceType} +
+ {/if} + + + {#if fetchedData} +
+ + +
+ +
+ + +
+ +
+ + +
+ + + {#if tracks.length > 0} +
+

+ {$translate('discovery.tracks')} ({$translate('discovery.trackCount', { + values: { count: tracks.length }, + })}) +

+
+ {#each tracks as track (track.position)} +
+ + {track.position}.{track.name} + + {#if track.duration_ms} + {formatDurationCompact(track.duration_ms)} + {/if} +
+ {/each} +
+
+ {/if} + {/if} +
+ {/if} +
+ {/snippet} +
diff --git a/apps/mobile/src/lib/components/discovery/BulkImportView.svelte b/apps/mobile/src/lib/components/discovery/BulkImportView.svelte new file mode 100644 index 00000000..bcd748fd --- /dev/null +++ b/apps/mobile/src/lib/components/discovery/BulkImportView.svelte @@ -0,0 +1,265 @@ + + +
+ +
+
+

+ {$translate('discovery.bulkImport.releasesFound', { values: { total: scannedPage.total_found } })} +

+ {#if scannedPage.already_in_discovery > 0} +

+ {$translate('discovery.bulkImport.alreadyInDiscovery', { + values: { count: scannedPage.already_in_discovery }, + })} +

+ {/if} +
+ {#if !result && selectableCount > 0} + + {/if} +
+ + + {#if scannedPage.releases.length === 0} +
+

{$translate('discovery.bulkImport.noReleasesFound')}

+
+ {:else} +
+ {#each scannedPage.releases as release (release.url)} + {@const isSelected = selectedUrls.has(release.url)} + {@const disabled = release.already_exists || importing} + + {/each} +
+ {/if} + + +
+ {#if importing && progress} + +
+
+
+
+
+

+ {$translate('discovery.bulkImport.importing', { + values: { current: progress.current, total: progress.total }, + })} +

+ +
+
+ {:else if result} +
+

+ {#if result.failed > 0} + {$translate('discovery.bulkImport.completedWithFailures', { + values: { succeeded: result.succeeded, failed: result.failed }, + })} + {:else} + {$translate('discovery.bulkImport.completed', { values: { succeeded: result.succeeded } })} + {/if} +

+ +
+ {:else} +
+ + +
+ {/if} +
+
diff --git a/apps/mobile/src/lib/components/discovery/DiscoveryToolbar.svelte b/apps/mobile/src/lib/components/discovery/DiscoveryToolbar.svelte new file mode 100644 index 00000000..8ffe6ec4 --- /dev/null +++ b/apps/mobile/src/lib/components/discovery/DiscoveryToolbar.svelte @@ -0,0 +1,98 @@ + + +
+ discoveryStore.setSearch(v)} + placeholder={$translate('discovery.searchPlaceholder')} + /> + + + + + + +
+ + (sortOpen = false)} /> + (filterOpen = false)} /> diff --git a/apps/mobile/src/lib/components/discovery/EditReleaseSheet.svelte b/apps/mobile/src/lib/components/discovery/EditReleaseSheet.svelte new file mode 100644 index 00000000..d2a6ff8e --- /dev/null +++ b/apps/mobile/src/lib/components/discovery/EditReleaseSheet.svelte @@ -0,0 +1,131 @@ + + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {#snippet footer()} +
+ + +
+ {/snippet} +
diff --git a/apps/mobile/src/lib/components/discovery/FilterSheet.svelte b/apps/mobile/src/lib/components/discovery/FilterSheet.svelte new file mode 100644 index 00000000..dc2c6dd2 --- /dev/null +++ b/apps/mobile/src/lib/components/discovery/FilterSheet.svelte @@ -0,0 +1,169 @@ + + + + {#snippet headerAction()} + {#if hasActiveFilters} + + {/if} + {/snippet} + +
+ + + + {#if $tagsStore.loading && $tagsStore.categories.length === 0} +

{$translate('common.loading')}

+ {:else if $tagsStore.categories.length > 0} +
+ + +
+ + + + + {$translate('library.matching')} + + +
+ + +
+ {#each $tagsStore.categories as category (category.id)} +
+

+ {category.name} +

+
+ {#each category.tags as tag (tag.id)} + {@const color = tag.color ?? category.color ?? '#888888'} + {@const on = active.has(tag.id)} + + {/each} +
+
+ {/each} +
+ {/if} +
+
diff --git a/apps/mobile/src/lib/components/discovery/MobileDiscoveryView.svelte b/apps/mobile/src/lib/components/discovery/MobileDiscoveryView.svelte new file mode 100644 index 00000000..5548378e --- /dev/null +++ b/apps/mobile/src/lib/components/discovery/MobileDiscoveryView.svelte @@ -0,0 +1,184 @@ + + +
+ + + +
+ +{#snippet releaseRow({ release })} + +{/snippet} + +{#snippet pendingBlock()} + {#if $pendingReleases.length > 0} +
+ {#each $pendingReleases as pending (pending.id)} + + {/each} +
+ {/if} +{/snippet} + +{#snippet emptyState()} + {#if $isDiscoveryLoading && totalReleases === 0} +
+ +
+ {:else if totalReleases === 0} + +
+
+ + + +
+
+

{$translate('discovery.noReleasesYet')}

+

{$translate('discovery.mobileAddHint')}

+
+ +
+ {:else} + +
+ {$translate('discovery.noResults')} +
+ {/if} +{/snippet} + + + diff --git a/apps/mobile/src/lib/components/discovery/MobileTagPicker.svelte b/apps/mobile/src/lib/components/discovery/MobileTagPicker.svelte new file mode 100644 index 00000000..6d6ea1c8 --- /dev/null +++ b/apps/mobile/src/lib/components/discovery/MobileTagPicker.svelte @@ -0,0 +1,102 @@ + + + + {#if $tagsStore.loading && $tagsStore.categories.length === 0} +

{$translate('common.loading')}

+ {:else if $tagsStore.categories.length === 0} +

{$translate('tags.noTags')}

+ {:else} +
+ {#each $tagsStore.categories as category (category.id)} +
+

+ {category.name} +

+
+ {#each category.tags as tag (tag.id)} + {@const color = tag.color ?? category.color ?? '#888888'} + {@const st = stateOf(tag.id)} + {@const lit = st === 'active' || st === 'mixed'} + + {/each} +
+
+ {/each} +
+ {/if} +
diff --git a/apps/mobile/src/lib/components/discovery/PendingReleaseCard.svelte b/apps/mobile/src/lib/components/discovery/PendingReleaseCard.svelte new file mode 100644 index 00000000..b0a8d06c --- /dev/null +++ b/apps/mobile/src/lib/components/discovery/PendingReleaseCard.svelte @@ -0,0 +1,53 @@ + + +
+
+ +
+
+

{domain()}

+
+ {#if pending.status === 'fetching'} + + {$translate('discovery.fetchingMetadata')} + {:else if pending.status === 'failed'} + + {$translate('discovery.fetchError')} + + {:else} + + {$translate('discovery.pending')} + + {/if} +
+
+ +
diff --git a/apps/mobile/src/lib/components/discovery/ReleaseCard.svelte b/apps/mobile/src/lib/components/discovery/ReleaseCard.svelte new file mode 100644 index 00000000..1cf1eb41 --- /dev/null +++ b/apps/mobile/src/lib/components/discovery/ReleaseCard.svelte @@ -0,0 +1,332 @@ + + +
+ + {#if revealPx > 0} +
+ + +
+ {/if} + + +
+ {#if isSelectMode} + + + + + + {/if} + + + + {#if !isSelectMode} + {#if isPreviewLoading} + + {:else} + + + + {/if} + {/if} +
+
diff --git a/apps/mobile/src/lib/components/discovery/ReleaseCardContent.svelte b/apps/mobile/src/lib/components/discovery/ReleaseCardContent.svelte new file mode 100644 index 00000000..73d3304d --- /dev/null +++ b/apps/mobile/src/lib/components/discovery/ReleaseCardContent.svelte @@ -0,0 +1,48 @@ + + + + {#snippet fallback()} +
+ + + +
+ {/snippet} +
+ +
+ + + + {release.title ?? $translate('common.untitled')} + + {#if release.is_new} + + {$translate('filters.new')} + + {/if} + + + {release.artist ?? $translate('common.unknownArtist')} + + + + {release.label ?? ' '} + +
diff --git a/apps/mobile/src/lib/components/discovery/ReleaseContextMenu.svelte b/apps/mobile/src/lib/components/discovery/ReleaseContextMenu.svelte new file mode 100644 index 00000000..50583fc8 --- /dev/null +++ b/apps/mobile/src/lib/components/discovery/ReleaseContextMenu.svelte @@ -0,0 +1,255 @@ + + + (displayed = null)}> + {#snippet preview()} + {#if displayed} + + {/if} + {/snippet} + + + {$translate('contextMenu.addToPlaylist')} + {#snippet icon()} + + + + {/snippet} + + + + {$translate('queue.playNext')} + {#snippet icon()} + + + + + {/snippet} + + + + {$translate('queue.addToQueue')} + {#snippet icon()} + + + + {/snippet} + + + {#if canFollow} + + {$translate('discovery.following.follow')} + {#snippet icon()} + + + + + + {/snippet} + + {/if} + + {#if context === 'playlist' && playlistId} + + {$translate('queue.reorder')} + {#snippet icon()} + + + + {/snippet} + + + + {$translate('contextMenu.removeFromPlaylist')} + {#snippet icon()} + + + + {/snippet} + + {/if} + + + {$translate('common.select')} + {#snippet icon()} + + + + + {/snippet} + + + + {platformName + ? $translate('discovery.openInApp', { values: { app: platformName } }) + : $translate('discovery.openInBrowser')} + {#snippet icon()} + {#if displayed}{/if} + {/snippet} + + + + {$translate('common.delete')} + {#snippet icon()} + + + + {/snippet} + + diff --git a/apps/mobile/src/lib/components/discovery/ReleaseDetail.svelte b/apps/mobile/src/lib/components/discovery/ReleaseDetail.svelte new file mode 100644 index 00000000..2552ff77 --- /dev/null +++ b/apps/mobile/src/lib/components/discovery/ReleaseDetail.svelte @@ -0,0 +1,775 @@ + + + + {#snippet children({ animating })} + +
+ +
+ + +
+ +
+ +
+ + {#snippet fallback()} +
+ + + +
+ {/snippet} +
+
+ + +
+
+

+ {release.title ?? $translate('common.untitled')}{#if release.is_new}{$translate('filters.new')}{/if} +

+

{release.artist ?? $translate('common.unknownArtist')}

+

+ {#if release.label}{release.label}{/if} + {#if release.label && release.release_date} + · + {/if} + {#if release.release_date}{formatDate(release.release_date)}{/if} +

+ {#if downloading} +

+ + {$translate('discovery.downloading')} +

+ {:else if isFullyDownloaded} +

+ + + + {$translate('discovery.downloadedForOffline')} +

+ {/if} +
+ + +
+ + +
+

+ {$translate('discovery.tracks')} +

+
+ {#if release.tracks.length === 0} + + {#if isRefreshing} +
+ +
+ {:else} +
{$translate('library.noTracksYet')}
+ {/if} + {/if} + {#each release.tracks as track, index (track.id)} + {@const isActive = isCurrentRelease && $previewInfo?.trackIndex === index} + {@const isLoading = spinnerArmed && loadingReleaseId === release.id && loadingTrackIndex === index} + +
startTrackLongPress(e, index)} + onclickcapture={onTrackClickCapture} + > + + +
+ {/each} +
+
+ + +
+

+ {$translate('nav.tags')} +

+
+ {#each release.tags as tag (tag.id)} + {@const color = tag.color ?? '#888888'} + + {tag.name} + + {/each} + +
+
+ + + {#if release.notes} +
+

+ {$translate('discovery.editor.notes')} +

+

{release.notes}

+
+ {/if} +
+
+ {/snippet} +
+ + (tagPickerOpen = false)} /> + (editSheetOpen = false)} /> + (playlistPickerOpen = false)} /> + + + (menuOpen = false)}> + + {$translate('contextMenu.addToPlaylist')} + {#snippet icon()} + + + + {/snippet} + + + + {$translate('queue.playNext')} + {#snippet icon()} + + + + + {/snippet} + + + + {$translate('queue.addToQueue')} + {#snippet icon()} + + + + {/snippet} + + + {#if canFollow} + + {$translate('discovery.following.follow')} + {#snippet icon()} + + + + + + {/snippet} + + {/if} + + + {$translate('discovery.editRelease')} + {#snippet icon()} + + + + + {/snippet} + + + + {$translate('discovery.refreshMetadata')} + {#snippet icon()} + + + + + {/snippet} + + + + {platformName + ? $translate('discovery.openInApp', { values: { app: platformName } }) + : $translate('discovery.openInBrowser')} + {#snippet icon()} + + {/snippet} + + + {#if release.tracks.length > 0 && !isFullyDownloaded} + + {downloading ? $translate('discovery.downloading') : $translate('discovery.downloadForOffline')} + {#snippet icon()} + + + + + + {/snippet} + + {/if} + + {#if hasSomeCached} + + {$translate('discovery.removeDownload')} + {#snippet icon()} + + + + {/snippet} + + {/if} + + + {$translate('discovery.deleteRelease')} + {#snippet icon()} + + + + {/snippet} + + + + + (trackMenuOpen = false)} + onClosed={() => { + actionTrackIndex = null + trackMenuRect = null + }} +> + {#snippet preview()} + {#if actionTrack} + + {(actionTrackIndex ?? 0) + 1} + + {actionTrack.name} + {#if actionTrack.duration_ms != null} + + {formatDurationCompact(actionTrack.duration_ms)} + + {/if} + {/if} + {/snippet} + + + {$translate('queue.playNext')} + {#snippet icon()} + + + + + {/snippet} + + + + {$translate('queue.addToQueue')} + {#snippet icon()} + + + + {/snippet} + + diff --git a/apps/mobile/src/lib/components/discovery/ReleaseFeedList.svelte b/apps/mobile/src/lib/components/discovery/ReleaseFeedList.svelte new file mode 100644 index 00000000..0a2718ab --- /dev/null +++ b/apps/mobile/src/lib/components/discovery/ReleaseFeedList.svelte @@ -0,0 +1,145 @@ + + + +
+ {#if onRefresh} + + {/if} + +
+ {#if leading}{@render leading()}{/if} + + {#if releases.length === 0} + {#if empty}{@render empty()}{/if} + {:else} + +
+ {#each virtualList.virtualItems as virtualItem (virtualItem.key)} + {@const release = releases[virtualItem.index]} + {#if release} +
+ {@render row({ release, index: virtualItem.index })} +
+ {/if} + {/each} +
+ {/if} +
+
diff --git a/apps/mobile/src/lib/components/discovery/SelectionBar.svelte b/apps/mobile/src/lib/components/discovery/SelectionBar.svelte new file mode 100644 index 00000000..8ab25502 --- /dev/null +++ b/apps/mobile/src/lib/components/discovery/SelectionBar.svelte @@ -0,0 +1,110 @@ + + +
+
+
+ + + {$translate('discovery.selectedCount', { values: { count } })} + +
+
+ {#if onAddToPlaylist} + + {/if} + {#if playlistId && onRemoveFromPlaylist} + + {/if} + + +
+
+
+ + (tagPickerOpen = false)} /> diff --git a/apps/mobile/src/lib/components/discovery/SortSheet.svelte b/apps/mobile/src/lib/components/discovery/SortSheet.svelte new file mode 100644 index 00000000..7f57e11f --- /dev/null +++ b/apps/mobile/src/lib/components/discovery/SortSheet.svelte @@ -0,0 +1,58 @@ + + + +
+ {#each options as opt (opt.field)} + {@const active = sort.field === opt.field} + + {/each} +
+
diff --git a/apps/mobile/src/lib/components/discovery/SourceIcon.svelte b/apps/mobile/src/lib/components/discovery/SourceIcon.svelte new file mode 100644 index 00000000..54d4b1da --- /dev/null +++ b/apps/mobile/src/lib/components/discovery/SourceIcon.svelte @@ -0,0 +1,44 @@ + + +{#if source === 'bandcamp'} + +{:else if source === 'soundcloud'} + +{:else if source === 'youtube'} + +{:else if source === 'discogs'} + +{:else} + +{/if} diff --git a/apps/mobile/src/lib/components/discovery/TrackListSkeleton.svelte b/apps/mobile/src/lib/components/discovery/TrackListSkeleton.svelte new file mode 100644 index 00000000..574a952a --- /dev/null +++ b/apps/mobile/src/lib/components/discovery/TrackListSkeleton.svelte @@ -0,0 +1,28 @@ + + + diff --git a/apps/mobile/src/lib/components/following/FollowDetailView.svelte b/apps/mobile/src/lib/components/following/FollowDetailView.svelte new file mode 100644 index 00000000..414c7429 --- /dev/null +++ b/apps/mobile/src/lib/components/following/FollowDetailView.svelte @@ -0,0 +1,174 @@ + + + + {#snippet children({ animating })} + +
+ + {#if source.artworkUrl} + + {:else} +
+ {#if source.followType === 'label'} + + + + + {:else} + + + + + {/if} +
+ {/if} +

+ {source.name ?? domain(source.url)} +

+
+ + + {#if $isDiscoveryLoading && $discoveryStore.releases.length === 0} +
+ +
+ {:else if releases.length === 0} +
+ {$translate('discovery.noReleasesYet')} +
+ {:else} + + {/if} + {/snippet} +
+ +{#snippet releaseRow({ release })} + +{/snippet} + +{#if isSelectMode} + +{/if} + + + + (pickerOpen = false)} /> diff --git a/apps/mobile/src/lib/components/following/FollowSheet.svelte b/apps/mobile/src/lib/components/following/FollowSheet.svelte new file mode 100644 index 00000000..5d57e3d1 --- /dev/null +++ b/apps/mobile/src/lib/components/following/FollowSheet.svelte @@ -0,0 +1,186 @@ + + + + {#if displayed} + {#if hasFollowable} +
+ +
+ {#if currentFollow?.artworkUrl} + + {:else} +
+ {#if selected === 'label'} + + + + + {:else} + + + + + {/if} +
+ {/if} +
+
{displayName}
+
{typeLabel} · {platformName}
+
+ +
+ + + {#if artistAvailable && labelAvailable} +
+
+ + +
+ {/if} +
+ {:else} +

{$translate('discovery.following.followViaPaste')}

+ {/if} + {/if} +
diff --git a/apps/mobile/src/lib/components/following/FollowingView.svelte b/apps/mobile/src/lib/components/following/FollowingView.svelte new file mode 100644 index 00000000..d71828fe --- /dev/null +++ b/apps/mobile/src/lib/components/following/FollowingView.svelte @@ -0,0 +1,384 @@ + + +{#snippet avatar(source: FollowedSource)} + {#if source.artworkUrl} + + {:else} +
+ {#if source.followType === 'label'} + + + + + + {:else} + + + + + + {/if} +
+ {/if} +{/snippet} + +{#snippet status(source: FollowedSource, checking: boolean)} + {#if checking} + + {:else if source.health === 'error' || source.health === 'rate_limited'} + + + + + + {$translate('discovery.following.sourceError')} + + {:else if source.newCount > 0} + + {$translate('discovery.following.newCount', { values: { count: source.newCount } })} + + {:else} + + {$translate('discovery.following.upToDate')} + + {/if} +{/snippet} + +
+ + {#if hasSources} +
+ (query = v)} + placeholder={$translate('discovery.following.searchPlaceholder')} + /> + +
+ {/if} + +
+ {#if hasSources} + + {/if} +
+ + {#if hasSources && filteredSources.length === 0} +
{$translate('common.noResults')}
+ {/if} + {#each filteredSources as source (source.id)} + + {@const checking = $followStore.checkingIds.has(source.id)} +
startLongPress(e, source)} onclickcapture={onRowClickCapture}> + openSourceDetail(source)}> + {#snippet leading()} + {@render avatar(source)} + {/snippet} + {#snippet trailing()} + {@render status(source, checking)} + {/snippet} + {source.name ?? domain(source.url)} + + {domain(source.url)}{source.lastCheckedAt + ? ` · ${$translate('discovery.following.checkedAgo', { values: { time: formatRelativeDate(source.lastCheckedAt, $translate) } })}` + : ''} + + +
+ {/each} +
+
+
+
+ +{#snippet emptyState()} + + {#snippet icon()} + + + + + + {/snippet} + +{/snippet} + + + (addOpen = false)} +/> + + + (actionsOpen = false)} + onClosed={() => { + actionTarget = null + longPressRect = null + }} +> + {#snippet preview()} + {#if actionTarget} + {@render avatar(actionTarget)} + + {actionTarget.name ?? domain(actionTarget.url)} + + {/if} + {/snippet} + + actionTarget && checkOne(actionTarget)}> + {$translate('discovery.following.checkNow')} + {#snippet icon()} + + + + + + {/snippet} + + + actionTarget && openSource(actionTarget)}> + {$translate('discovery.openInBrowser')} + {#snippet icon()} + + + + + + {/snippet} + + + actionTarget && unfollow(actionTarget)}> + {$translate('discovery.following.unfollow')} + {#snippet icon()} + + + + {/snippet} + + diff --git a/apps/mobile/src/lib/components/layout/Header.svelte b/apps/mobile/src/lib/components/layout/Header.svelte new file mode 100644 index 00000000..67d3245a --- /dev/null +++ b/apps/mobile/src/lib/components/layout/Header.svelte @@ -0,0 +1,53 @@ + + +
+
+ +
+ + +
+

+ {title} +

+
+ + +
+ + +
+
+
diff --git a/apps/mobile/src/lib/components/layout/MobileShell.svelte b/apps/mobile/src/lib/components/layout/MobileShell.svelte new file mode 100644 index 00000000..554baabf --- /dev/null +++ b/apps/mobile/src/lib/components/layout/MobileShell.svelte @@ -0,0 +1,105 @@ + + +
+
+ +
+ +
+ {#key $activeTab} +
+ {#if $activeTab === 'discovery'} + + {:else if $activeTab === 'following'} + + {:else if $activeTab === 'playlists'} + + {:else} + + {/if} +
+ {/key} +
+
+ + + + + {#if $selectMode && !$detailPlaylistId && !$detailTagId && !$detailFollowSourceId} + + {/if} +
+ + + + (feedPickerOpen = false)} /> diff --git a/apps/mobile/src/lib/components/layout/SettingsButton.svelte b/apps/mobile/src/lib/components/layout/SettingsButton.svelte new file mode 100644 index 00000000..ae002143 --- /dev/null +++ b/apps/mobile/src/lib/components/layout/SettingsButton.svelte @@ -0,0 +1,35 @@ + + + diff --git a/apps/mobile/src/lib/components/layout/SyncStatusButton.svelte b/apps/mobile/src/lib/components/layout/SyncStatusButton.svelte new file mode 100644 index 00000000..5ab2c039 --- /dev/null +++ b/apps/mobile/src/lib/components/layout/SyncStatusButton.svelte @@ -0,0 +1,101 @@ + + +{#if $isSyncAvailable} + +{/if} diff --git a/apps/mobile/src/lib/components/layout/TabBar.svelte b/apps/mobile/src/lib/components/layout/TabBar.svelte new file mode 100644 index 00000000..60b1922b --- /dev/null +++ b/apps/mobile/src/lib/components/layout/TabBar.svelte @@ -0,0 +1,148 @@ + + + diff --git a/apps/mobile/src/lib/components/onboarding/MobileOnboarding.svelte b/apps/mobile/src/lib/components/onboarding/MobileOnboarding.svelte new file mode 100644 index 00000000..c1f89bec --- /dev/null +++ b/apps/mobile/src/lib/components/onboarding/MobileOnboarding.svelte @@ -0,0 +1,320 @@ + + +
+ +
+ +
+ + +
+ {#key current} +
+ {#if step === 'welcome'} +
+
+

+ {$translate('onboarding.mobile.welcome.title', { values: { appName: 'Crate' } })} +

+

{$translate('onboarding.mobile.welcome.subtitle')}

+
+ {:else if step === 'discover'} +
+ + + +
+
+

{$translate('onboarding.mobile.discover.title')}

+

{$translate('onboarding.mobile.discover.description')}

+
+ {:else if step === 'sync'} +
+ + + +
+
+

{$translate('onboarding.mobile.sync.title')}

+

{$translate('onboarding.mobile.sync.description')}

+
+ + + {#if $cloudSyncError} +

{$cloudSyncError}

+ {/if} + {:else} +
+

{$translate('onboarding.mobile.appearance.title')}

+

{$translate('onboarding.mobile.appearance.description')}

+
+ + +
+

+ {$translate('settings.appearance.theme')} +

+
+ {#each themeOptions as option (option.value)} + + {/each} +
+
+ + +
+

+ {$translate('settings.appearance.accentColor')} +

+
+ {#each accentColors as color (color.value)} + + {/each} +
+
+ {/if} +
+ {/key} +
+ + +
+ + + +
+ {#each steps as stepName, i (stepName)} + + {/each} +
+ +
+ {#if step === 'sync'} + + + {:else} + + {/if} +
+
+
diff --git a/apps/mobile/src/lib/components/player/CoverPager.svelte b/apps/mobile/src/lib/components/player/CoverPager.svelte new file mode 100644 index 00000000..e56347ab --- /dev/null +++ b/apps/mobile/src/lib/components/player/CoverPager.svelte @@ -0,0 +1,399 @@ + + + + +{#snippet fallbackTile()} +
+ + + +
+{/snippet} + +
+
+
+ + {#if storeSlide?.dir === 1} +
+ {#if storeSlide.outgoingSrc} + + {:else} + {@render fallbackTile()} + {/if} +
+ {:else if prevSlotPick} +
+ +
+ {/if} + + +
+ {#if centerPick} + + {:else if centerSrc} + + {:else} + {@render fallbackTile()} + {/if} +
+ + + {#if storeSlide?.dir === -1} +
+ {#if storeSlide.outgoingSrc} + + {:else} + {@render fallbackTile()} + {/if} +
+ {:else if nextSlotPick} +
+ +
+ {/if} +
+
+
diff --git a/apps/mobile/src/lib/components/player/ExpandedPlayer.svelte b/apps/mobile/src/lib/components/player/ExpandedPlayer.svelte new file mode 100644 index 00000000..fdb7a920 --- /dev/null +++ b/apps/mobile/src/lib/components/player/ExpandedPlayer.svelte @@ -0,0 +1,661 @@ + + + + {#snippet children({ dragging })} + {#if $previewInfo} + + {#key $previewInfo.releaseId} +
+ {#if artSrc} + +
+ {/if} +
+ {/key} + +
+ + + + + + + +
+
+ +
+ {#key `${$previewInfo.releaseId}:${$previewInfo.trackIndex}`} +
+
+ + +
+
+ + {$previewInfo.release.artist ?? $translate('common.unknownArtist')} + +
+
+ {/key} +
+ +
+ + +
+ e.stopPropagation()} + /> +
+ {formatDuration(sliderValue)} + {formatDuration($playbackDuration)} +
+
+ + +
+ + + + +
+ + + +
+ + + + +
+ + + {#if showTempo} +
+
+ + {tempoPct >= 0 ? '+' : ''}{tempoPct.toFixed(1)}% + +
+ e.stopPropagation()} + /> +
+
+ +
+
+
+ {/if} +
+
+ {/if} + {/snippet} +
+ + (showQueue = false)} /> + +{#if $previewInfo} + (playlistPickerOpen = false)} + /> + (editSheetOpen = false)} /> +{/if} + + + (menuOpen = false)}> + + {$translate('contextMenu.addToPlaylist')} + {#snippet icon()} + + + + {/snippet} + + + + {$translate('discovery.goToRelease')} + {#snippet icon()} + + + + {/snippet} + + + + {$translate('discovery.editRelease')} + {#snippet icon()} + + + + + {/snippet} + + + + {platformName + ? $translate('discovery.openInApp', { values: { app: platformName } }) + : $translate('discovery.openInBrowser')} + {#snippet icon()} + {#if $previewInfo}{/if} + {/snippet} + + diff --git a/apps/mobile/src/lib/components/player/MiniPlayer.svelte b/apps/mobile/src/lib/components/player/MiniPlayer.svelte new file mode 100644 index 00000000..f7772d97 --- /dev/null +++ b/apps/mobile/src/lib/components/player/MiniPlayer.svelte @@ -0,0 +1,123 @@ + + +{#if $previewInfo} +
+
+ + + + + + + +
+
+
+
+
+{/if} diff --git a/apps/mobile/src/lib/components/player/UpNextSheet.svelte b/apps/mobile/src/lib/components/player/UpNextSheet.svelte new file mode 100644 index 00000000..c6431a09 --- /dev/null +++ b/apps/mobile/src/lib/components/player/UpNextSheet.svelte @@ -0,0 +1,170 @@ + + + + {#snippet headerAction()} + {#if $userQueueCount > 0} + + {/if} + {/snippet} + + {#if userEntries.length === 0 && contextEntries.length === 0} +
+

{$translate('queue.empty')}

+

{$translate('queue.emptyHint')}

+
+ {:else} + + {#if userEntries.length > 0} +

+ {$translate('queue.nextInQueue')} +

+
+ {#each userEntries as entry (entry.key)} +
+ + + + + {#snippet fallback()} +
+ {/snippet} +
+ +
+ {trackName(entry)} + + {entry.release.artist ?? $translate('common.unknownArtist')} + +
+ + +
+ {/each} +
+ {/if} + + + {#if contextEntries.length > 0} +

+ {$translate('queue.upNextFromContext')} +

+
+ {#each contextEntries as entry (entry.key)} +
+ + {#snippet fallback()} +
+ {/snippet} +
+
+ {trackName(entry)} + + {entry.release.artist ?? $translate('common.unknownArtist')} + +
+
+ {/each} +
+ {/if} + {/if} +
diff --git a/apps/mobile/src/lib/components/playlists/PlaylistDetailView.svelte b/apps/mobile/src/lib/components/playlists/PlaylistDetailView.svelte new file mode 100644 index 00000000..41c711c1 --- /dev/null +++ b/apps/mobile/src/lib/components/playlists/PlaylistDetailView.svelte @@ -0,0 +1,249 @@ + + + + {#snippet children({ animating })} + +
+
+ +

{playlist.name}

+
+ + {#if !playlist.is_smart} +
+ {#if isReorderMode} + + {:else} + + {/if} +
+ {/if} +
+ + + {#if loading} +
+ +
+ {:else if releases.length === 0} +
+ + {#snippet icon()} + + + + {/snippet} + +
+ {:else if isReorderMode} +
+ +
+ {:else} + + {/if} + {/snippet} +
+ +{#snippet releaseRow({ release })} + +{/snippet} + +{#if isSelectMode} + +{/if} + + + + + (pickerOpen = false)} /> diff --git a/apps/mobile/src/lib/components/playlists/PlaylistPickerSheet.svelte b/apps/mobile/src/lib/components/playlists/PlaylistPickerSheet.svelte new file mode 100644 index 00000000..15e0340f --- /dev/null +++ b/apps/mobile/src/lib/components/playlists/PlaylistPickerSheet.svelte @@ -0,0 +1,264 @@ + + + + +
+ +
+
+ + + + + + {#if query} + + {/if} +
+
+ + {#if query.trim()} + + {#each searchResults as playlist (playlist.id)} + addTo(playlist.id)}> + {#snippet leading()} + + {/snippet} + {#snippet trailing()} + {playlist.track_count} + {/snippet} + {playlist.name} + + {/each} + {@render createRow()} + {:else} + + {#if folderStack.length > 0} + + {/if} + + {@render createRow()} + + {#key currentFolderId} +
+ {#each browseItems as item (item.id)} + {#if item.is_folder} + pushFolder(item.id)}> + {#snippet leading()} +
+ + + +
+ {/snippet} + {#snippet trailing()} + + + + {/snippet} + {item.name} +
+ {:else} + addTo(item.id)}> + {#snippet leading()} + + {/snippet} + {#snippet trailing()} + {item.track_count} + {/snippet} + {item.name} + + {/if} + {/each} + + {#if browseItems.length === 0} +

+ {folderStack.length > 0 ? $translate('playlists.folderEmpty') : $translate('playlists.noPlaylists')} +

+ {/if} +
+ {/key} + {/if} +
+
+ + +{#snippet createRow()} + {#if creating} +
+ e.key === 'Enter' && createAndAdd()} + autofocus + /> + +
+ {:else} + + {#snippet leading()} +
+ + + +
+ {/snippet} + {$translate('playlists.newPlaylist')} +
+ {/if} +{/snippet} diff --git a/apps/mobile/src/lib/components/playlists/PlaylistThumbnail.svelte b/apps/mobile/src/lib/components/playlists/PlaylistThumbnail.svelte new file mode 100644 index 00000000..b2da360c --- /dev/null +++ b/apps/mobile/src/lib/components/playlists/PlaylistThumbnail.svelte @@ -0,0 +1,42 @@ + + +
+ {#if urls.length >= 4} +
+ {#each urls.slice(0, 4) as url (url)} + + {/each} +
+ {:else if urls.length > 0} + + {:else} +
+ + + +
+ {/if} + + {#if smart} + + {/if} +
diff --git a/apps/mobile/src/lib/components/playlists/PlaylistsView.svelte b/apps/mobile/src/lib/components/playlists/PlaylistsView.svelte new file mode 100644 index 00000000..6d31fc7d --- /dev/null +++ b/apps/mobile/src/lib/components/playlists/PlaylistsView.svelte @@ -0,0 +1,565 @@ + + +
+ +
+ (query = v)} + placeholder={$translate('playlists.searchPlaceholder')} + /> + +
+ + +
+ {#key currentFolderId} +
+ {#if folderStack.length > 0} +
+ + {currentFolder?.name ?? ''} +
+ {/if} + + {#if $playlistsStore.loading && allPlaylists.length === 0} + +
+ +
+ {:else} + + {#each filteredChildren as item (item.id)} + {#if item.is_folder} + {@const childCount = getPlaylistChildren(allPlaylists, item.id).length} +
startLongPress(e, item)} onclickcapture={onRowClickCapture}> + pushFolder(item.id)}> + {#snippet leading()} +
+ + + +
+ {/snippet} + {#snippet trailing()} + + + + {/snippet} + {item.name} + + {childCount} + {childCount === 1 ? $translate('library.item') : $translate('library.items')} + +
+
+ {:else} +
startLongPress(e, item)} onclickcapture={onRowClickCapture}> + openPlaylist(item.id)}> + {#snippet leading()} + + {/snippet} + {item.name} + + {item.track_count} + {item.track_count === 1 ? $translate('discovery.release') : $translate('discovery.releases')} + + +
+ {/if} + {/each} +
+ {/if} +
+ {/key} +
+
+ +{#snippet emptyState()} + {#if query.trim()} +
{$translate('common.noResults')}
+ {:else if folderStack.length > 0} + + {#snippet icon()} + + + + {/snippet} + + {:else} + openCreate('playlist')} + > + {#snippet icon()} + + + + {/snippet} + + {/if} +{/snippet} + + + (addMenuOpen = false)}> + openCreate('folder')}> + {$translate('playlists.newFolder')} + {#snippet icon()} + + + + {/snippet} + + + openCreate('playlist')}> + {$translate('playlists.newPlaylist')} + {#snippet icon()} + + + + {/snippet} + + + + {$translate('playlists.newSmartPlaylist')} + {#snippet icon()} + + + + {/snippet} + + + + + (createModalOpen = false)} +/> + + + (renameModalOpen = false)} +/> + + + (smartEditorOpen = false)} +/> + + + (rowActionsOpen = false)} + onClosed={() => { + longPressTarget = null + longPressRect = null + }} +> + {#snippet preview()} + {#if longPressTarget} + + {#if longPressTarget.is_folder} +
+ + + +
+ {:else} + + {/if} +
+ {longPressTarget.name} + {/if} + {/snippet} + + longPressTarget && openRename(longPressTarget)}> + {$translate('common.rename')} + {#snippet icon()} + + + + {/snippet} + + + {#if longPressTarget?.is_smart} + longPressTarget && openSmartEdit(longPressTarget)}> + {$translate('smartPlaylist.editTitle')} + {#snippet icon()} + + + + {/snippet} + + {/if} + + longPressTarget && handleDelete(longPressTarget)}> + {$translate('common.delete')} + {#snippet icon()} + + + + {/snippet} + +
diff --git a/apps/mobile/src/lib/components/playlists/SmartPlaylistEditor.svelte b/apps/mobile/src/lib/components/playlists/SmartPlaylistEditor.svelte new file mode 100644 index 00000000..b32a5a73 --- /dev/null +++ b/apps/mobile/src/lib/components/playlists/SmartPlaylistEditor.svelte @@ -0,0 +1,382 @@ + + + + {#snippet children({ animating })} + +
+ +

{title}

+ +
+ +
+
+ + + + +
+ {$translate('smartPlaylist.matchLabel')} +
+
+ + +
+
+ + +
+ {#if conditions.length === 0} +

+ {$translate('smartPlaylist.noConditions')} +

+ {/if} + + {#each conditions as condition, index (index)} +
+
+ + +
+ + + + + {#if operatorRequiresValue(condition.operator)} + {#if condition.type === 'tags'} +
+ {#each tagCategories as category (category.id)} + {#each category.tags as tag (tag.id)} + {@const color = tag.color ?? category.color ?? DEFAULT_TAG_COLOR} + {@const selected = condition.type === 'tags' && condition.tag_ids.includes(tag.id)} + + {/each} + {/each} +
+ {:else if condition.type === 'enum'} + + {:else if condition.type === 'numeric'} +
+ updateValue(index, e.currentTarget.value)} + class="min-w-0 flex-1 rounded-md border border-stroke bg-surface-0 px-2 py-2 text-sm text-text-primary" + /> + {#if operatorRequiresSecondValue(condition.operator)} + + updateValue2(index, e.currentTarget.value)} + class="min-w-0 flex-1 rounded-md border border-stroke bg-surface-0 px-2 py-2 text-sm text-text-primary" + /> + {/if} +
+ {:else if condition.type === 'date' && (condition.operator === 'before' || condition.operator === 'after')} + updateValue(index, e.currentTarget.value)} + class="w-full rounded-md border border-stroke bg-surface-0 px-2 py-2 text-sm text-text-primary" + /> + {:else if condition.type === 'date'} + updateValue(index, e.currentTarget.value)} + class="w-full rounded-md border border-stroke bg-surface-0 px-2 py-2 text-sm text-text-primary" + /> + {:else} + updateValue(index, e.currentTarget.value)} + class="w-full rounded-md border border-stroke bg-surface-0 px-2 py-2 text-sm text-text-primary" + /> + {/if} + {/if} +
+ {/each} + + +
+ + + {#if conditions.length > 0} +

+ {#if previewLoading} + … + {:else if previewCount !== null} + {$translate('smartPlaylist.preview', { values: { count: previewCount } })} + {/if} +

+ {/if} +
+
+ {/snippet} +
diff --git a/apps/mobile/src/lib/components/playlists/SortableReleaseList.svelte b/apps/mobile/src/lib/components/playlists/SortableReleaseList.svelte new file mode 100644 index 00000000..20ec54d8 --- /dev/null +++ b/apps/mobile/src/lib/components/playlists/SortableReleaseList.svelte @@ -0,0 +1,118 @@ + + +
+ {#each items as release, index (release.id)} + {@const isDragging = dragIndex === index} + +
+ + + +
+ {/each} +
diff --git a/apps/mobile/src/lib/components/settings/SettingsDrawer.svelte b/apps/mobile/src/lib/components/settings/SettingsDrawer.svelte new file mode 100644 index 00000000..e045254f --- /dev/null +++ b/apps/mobile/src/lib/components/settings/SettingsDrawer.svelte @@ -0,0 +1,56 @@ + + + + +
+ +

+ {$translate('settings.title')} +

+
+ + +
+ +
+
diff --git a/apps/mobile/src/lib/components/settings/SettingsView.svelte b/apps/mobile/src/lib/components/settings/SettingsView.svelte new file mode 100644 index 00000000..6b63bdcc --- /dev/null +++ b/apps/mobile/src/lib/components/settings/SettingsView.svelte @@ -0,0 +1,283 @@ + + +
+ +
+

+ {$translate('settings.tabs.appearance')} +

+
+ {#each themeOptions as option (option.value)} + + {/each} +
+ +

+ {$translate('settings.appearance.accentColor')} +

+
+ {#each accentColors as color (color.value)} + + {/each} +
+
+ + +
+

+ {$translate('settings.tabs.cloudSync')} +

+ +
+ + +
+

+ {$translate('settings.discovery.previewCache')} +

+ + +
+

+ {$translate('settings.discovery.audioCache')} · {formatFileSize(cacheSize)} +

+ +
+
+ {$translate('settings.discovery.cacheLimit')} +
+ {#each audioCachePresets as mb (mb)} + + {/each} +
+
+ + +

+ {$translate('settings.discovery.artworkCacheDescription')} +

+
+

+ {$translate('settings.discovery.artworkCache')} · {formatFileSize(artworkCacheSize)} +

+ +
+
+ {$translate('settings.discovery.cacheLimit')} +
+ {#each artworkCachePresets as mb (mb)} + + {/each} +
+
+
+ + +
+

+ {$translate('settings.tabs.about')} +

+
+
+

{$translate('settings.about.version')}

+

{PUBLIC_APP_VERSION}

+
+
+

{$translate('settings.about.project')}

+ +
+
+
+
diff --git a/apps/mobile/src/lib/components/tags/TagCategoryPickerSheet.svelte b/apps/mobile/src/lib/components/tags/TagCategoryPickerSheet.svelte new file mode 100644 index 00000000..e73a128a --- /dev/null +++ b/apps/mobile/src/lib/components/tags/TagCategoryPickerSheet.svelte @@ -0,0 +1,53 @@ + + + + +
+ {#each categories as category (category.id)} + {@const current = category.id === currentCategoryId} + choose(category.id)}> + {#snippet leading()} + + {/snippet} + {category.name} + {#snippet trailing()} + {#if current} + + + + {/if} + {/snippet} + + {/each} +
+
diff --git a/apps/mobile/src/lib/components/tags/TagColorPicker.svelte b/apps/mobile/src/lib/components/tags/TagColorPicker.svelte new file mode 100644 index 00000000..6e9b661c --- /dev/null +++ b/apps/mobile/src/lib/components/tags/TagColorPicker.svelte @@ -0,0 +1,45 @@ + + + +
+ {#each TAG_CATEGORY_COLORS as color (color.id)} + {@const selected = current === color.hex} + + {/each} +
+
diff --git a/apps/mobile/src/lib/components/tags/TagDetailView.svelte b/apps/mobile/src/lib/components/tags/TagDetailView.svelte new file mode 100644 index 00000000..8445f375 --- /dev/null +++ b/apps/mobile/src/lib/components/tags/TagDetailView.svelte @@ -0,0 +1,140 @@ + + + + {#snippet children({ animating })} + +
+ + +

{tag.name}

+
+ + + {#if $isDiscoveryLoading && $discoveryStore.releases.length === 0} +
+ +
+ {:else if releases.length === 0} +
+ {$translate('discovery.noReleasesYet')} +
+ {:else} + + {/if} + {/snippet} +
+ +{#snippet releaseRow({ release })} + +{/snippet} + +{#if isSelectMode} + +{/if} + + + + (pickerOpen = false)} /> diff --git a/apps/mobile/src/lib/components/tags/TagsView.svelte b/apps/mobile/src/lib/components/tags/TagsView.svelte new file mode 100644 index 00000000..dff67bb6 --- /dev/null +++ b/apps/mobile/src/lib/components/tags/TagsView.svelte @@ -0,0 +1,563 @@ + + +
+
+ + {#if categories.length > 0} +
+ +
+ {/if} + + {#if categories.length === 0} + +
+ + + +
{$translate('tags.noTagCategoriesYet')}
+
{$translate('tags.emptyHint')}
+ +
+ {:else} + {#each categories as category (category.id)} +
+ +
+

startLongPress(e, { type: 'category', category })} + onclickcapture={onRowClickCapture} + > + {category.name} +

+ +
+ + +
+ {#each category.tags as tag (tag.id)} + {@const color = tag.color ?? category.color ?? DEFAULT_TAG_COLOR} +
startLongPress(e, { type: 'tag', tag, category })} + onclickcapture={onRowClickCapture} + in:scale={{ duration: 180, start: 0.85, easing: easeFluid }} + out:scale={{ duration: 140, start: 0.85, easing: easeFluid }} + > + +
+ {/each} + + {#if category.tags.length === 0} + + {$translate('tags.noTags')} + + {/if} +
+
+ {/each} + {/if} +
+
+ + + (createCategoryOpen = false)} +/> + + + (createTagOpen = false)} +/> + + + (renameOpen = false)} +/> + + + (colorPickerOpen = false)} +/> + + + (movePickerOpen = false)} +/> + + +{#snippet rowPreview()} + {#if lpCategory} + {lpCategory.name} + {:else if lpTag} + + {lpTag.tag.name} + {/if} +{/snippet} + + (rowActionsOpen = false)} + onClosed={() => { + longPressTarget = null + longPressRect = null + menuByTap = false + }} +> + {#if lpCategory} + + {$translate('tags.addTag')} + {#snippet icon()} + + + + {/snippet} + + + {$translate('common.rename')} + {#snippet icon()} + + + + {/snippet} + + + {$translate('contextMenu.setColor')} + {#snippet icon()} + + + + {/snippet} + + + {$translate('common.delete')} + {#snippet icon()} + + + + {/snippet} + + {:else if lpTag} + + {$translate('common.rename')} + {#snippet icon()} + + + + {/snippet} + + {#if categories.length > 1} + + {$translate('tags.moveToCategory')} + {#snippet icon()} + + + + {/snippet} + + {/if} + + {$translate('common.delete')} + {#snippet icon()} + + + + {/snippet} + + {/if} + diff --git a/apps/mobile/src/lib/easing.ts b/apps/mobile/src/lib/easing.ts new file mode 100644 index 00000000..c02dc08d --- /dev/null +++ b/apps/mobile/src/lib/easing.ts @@ -0,0 +1,34 @@ +/** + * Cubic-bézier easing for Svelte JS transitions (fly / slide / fade), matching the CSS `--ease-fluid` + * token in `style.css` — `cubic-bezier(0.32, 0.72, 0, 1)`, the iOS-sheet curve. Keeping the CSS-driven + * sheets/drawers and the Svelte-driven transitions on one conventional, smooth curve avoids the flat + * "linear" feel and keeps everything consistent. + */ +function cubicBezier(x1: number, y1: number, x2: number, y2: number): (t: number) => number { + const ax = 3 * x1 - 3 * x2 + 1 + const bx = 3 * x2 - 6 * x1 + const cx = 3 * x1 + const ay = 3 * y1 - 3 * y2 + 1 + const by = 3 * y2 - 6 * y1 + const cy = 3 * y1 + const sampleX = (t: number) => ((ax * t + bx) * t + cx) * t + const sampleY = (t: number) => ((ay * t + by) * t + cy) * t + const slopeX = (t: number) => (3 * ax * t + 2 * bx) * t + cx + + return (x: number) => { + if (x <= 0) return 0 + if (x >= 1) return 1 + // Newton–Raphson: invert x(t) for the given progress x, then evaluate y(t). + let t = x + for (let i = 0; i < 6; i++) { + const dx = sampleX(t) - x + if (Math.abs(dx) < 1e-5) break + const slope = slopeX(t) + if (Math.abs(slope) < 1e-6) break + t -= dx / slope + } + return sampleY(t) + } +} + +export const easeFluid = cubicBezier(0.32, 0.72, 0, 1) diff --git a/apps/mobile/src/lib/signInMobile.ts b/apps/mobile/src/lib/signInMobile.ts new file mode 100644 index 00000000..b7c186fb --- /dev/null +++ b/apps/mobile/src/lib/signInMobile.ts @@ -0,0 +1,14 @@ +import { authenticate } from 'tauri-plugin-web-auth-api' +import { cloudSyncStore } from '$shared/stores/cloudSync' + +/** + * Run the native mobile OAuth sign-in for `providerId` (v1: `'google'`). + * + * This is the one place the `tauri-plugin-web-auth` import lives, so it never enters `shared/` + * (or the desktop bundle). The actual flow — `begin_sign_in` → present the native auth session + * (iOS `ASWebAuthenticationSession` / Android Custom Tabs) → `complete_sign_in` — is orchestrated + * by the shared cloud-sync store, which receives `authenticate` by injection. + */ +export function signInMobile(providerId = 'google'): Promise { + return cloudSyncStore.signInMobile(providerId, authenticate) +} diff --git a/apps/mobile/src/lib/stores/appData.ts b/apps/mobile/src/lib/stores/appData.ts new file mode 100644 index 00000000..94a48cac --- /dev/null +++ b/apps/mobile/src/lib/stores/appData.ts @@ -0,0 +1,14 @@ +import { readable } from 'svelte/store' +import { appDataDir } from '@tauri-apps/api/path' + +/** + * The app data directory, resolved once at load. Starts `null` and fills in asynchronously + * (the path API is async). Used to build Tauri asset URLs for on-disk cached discovery + * artwork so covers render offline / in airplane mode. Until it resolves, the artwork + * resolver falls back to the remote URL. + */ +export const mobileAppDataDir = readable(null, (set) => { + void appDataDir() + .then((dir) => set(dir)) + .catch(() => set(null)) +}) diff --git a/apps/mobile/src/lib/stores/mobileUI.ts b/apps/mobile/src/lib/stores/mobileUI.ts new file mode 100644 index 00000000..b4e73f66 --- /dev/null +++ b/apps/mobile/src/lib/stores/mobileUI.ts @@ -0,0 +1,664 @@ +import { writable, derived, get } from 'svelte/store' +import { sortedReleases, discoveryStore } from '$shared/stores/discovery' +import { discoveryPlaylistReleases } from '$shared/stores/discoveryPlaylist' +import { followedSources } from '$shared/stores/follow' +import { releasesFromSource } from '$shared/utils' +import { + getStoredArray, + getStoredNumber, + getStoredString, + removeStored, + setStoredArray, + setStoredNumber, + setStoredString, +} from '$shared/utils/storage' +import type { DiscoveryRelease, TagFilterMode } from '$shared/types' + +/** The app's primary navigation destinations, surfaced as bottom tabs. Settings is intentionally NOT a + * tab — it opens as a right-side drawer from the Header's gear button (see `openSettings`). */ +export type MobileTab = 'discovery' | 'following' | 'playlists' | 'tags' + +/** Where a preview-playback session was started from — selects which list scopes next / shuffle, and + * whether the discovery feed's live filter changes should keep re-scoping it. */ +export type PlaybackContextOrigin = 'discovery' | 'playlist' | 'tag' | 'follow' + +interface MobileUIState { + /** Which bottom tab is active — selects the view rendered in the content area. */ + activeTab: MobileTab + /** Discovery release whose detail screen is open (full-screen overlay), or null for the feed. */ + detailReleaseId: string | null + /** + * Whether the detail screen is in its covering position (over the tab bar). Distinct from + * `detailReleaseId`, which stays set until the slide-out animation finishes (so `+page` keeps the + * drawer mounted): this flips false the moment a close *starts*, so the mini-player can begin sliding + * back up *as* the drawer slides out rather than after it. + */ + detailCovering: boolean + /** Whether the preview player is expanded to the full-screen view (vs. the mini bar). */ + playerExpanded: boolean + /** + * One-shot: release the discovery feed should scroll into view. Set when locating the playing + * release from the expanded player; the feed clears it via `consumeScrollTarget` once it scrolls. + */ + scrollTargetReleaseId: string | null + /** + * Last scroll offset (px) of the discovery feed. Persisted here because the shell remounts the feed + * on every return to the Discovery tab (`{#key activeTab}`), recreating its scroll container — so the + * feed saves its offset as the user scrolls and restores it on mount, keeping their place after a + * long scroll through the releases. + */ + discoveryScrollTop: number + /** + * Active tag-filter IDs for the discovery feed. Filtering is client-side (AND/OR over the already + * loaded set) — unlike desktop, which reloads from the DB — so it stays instant and never resets the + * feed's scroll position. Held here (not the shared `uiStore`) so it's self-contained to mobile. + */ + tagFilterIds: string[] + /** Whether the tag filter requires ALL selected tags (`and`) or ANY (`or`). */ + tagFilterMode: TagFilterMode + /** Whether the feed is in multi-select mode (entered by long-pressing a release). */ + selectMode: boolean + /** Releases selected while in multi-select mode (batch delete / batch tag). */ + selectedReleaseIds: Set + /** Whether the add-release sheet is open. The sheet is a placeholder this pass; #56 fills it in. */ + addReleaseOpen: boolean + /** The one release row whose swipe-to-delete action is revealed — opening another closes it. */ + openRowId: string | null + /** Whether the settings drawer is mounted (a full-width right-side overlay). Mirrors `detailReleaseId` + * as the mount flag; stays set until the slide-out animation finishes so `+page` keeps it mounted. The + * drawer is opaque and sits above the mini-player (z-45 > z-40), so — unlike the detail overlays that + * the mini-player floats *above* — no `covering` flag is needed: it simply covers the mini-player. */ + settingsOpen: boolean + /** One-shot: settings section to scroll into view after the settings drawer opens. */ + settingsScrollTarget: string | null + /** Discovery playlist whose detail screen is open (full-screen overlay), or null. */ + detailPlaylistId: string | null + /** Whether the playlist detail is in its covering position (mirrors detailCovering). */ + playlistDetailCovering: boolean + /** Whether the playlist detail is in reorder mode (long-press-drag to reorder releases). */ + playlistReorderMode: boolean + /** Tag whose detail screen (its filtered release feed) is open (full-screen overlay), or null. */ + detailTagId: string | null + /** Whether the tag detail is in its covering position (mirrors detailCovering). */ + tagDetailCovering: boolean + /** Followed source (artist/label) whose detail screen — its releases — is open (overlay), or null. */ + detailFollowSourceId: string | null + /** Whether the follow detail is in its covering position (mirrors detailCovering). */ + followDetailCovering: boolean + /** Release ID for which the actions sheet is open, or null. */ + actionsReleaseId: string | null + /** Context in which the actions sheet was opened — determines available actions. */ + actionsContext: 'feed' | 'playlist' | 'tag' | 'follow' | null + /** + * Viewport rect of the long-pressed row (captured at long-press fire-time), so the context menu can + * lift a preview of it in place and anchor the platter to it. Plain snapshot (not a live `DOMRect`). + */ + actionsAnchorRect: { top: number; left: number; width: number; height: number } | null + /** + * Release whose inline follow-artist/label sheet is open, or null. Set from the release context menu's + * "Follow" action; a single `FollowSheet` (mounted in `+page`) opens for it, so the action works the same + * from the feed, playlist-detail, and tag-detail context menus without threading props through any of them. + */ + followReleaseId: string | null + /** + * The origin of the ACTIVE preview-playback session (captured when playback starts), or null when + * nothing is playing. Only a `discovery`-origin queue keeps following the feed's live filter; tag / + * follow / playlist queues are fixed snapshots of the list they started from. + */ + queueOrigin: PlaybackContextOrigin | null + /** + * Bumped when the active tab is re-tapped with nothing to pop — the mounted view scrolls to the top + * (iOS "tap the active tab to scroll to top"). A nonce rather than a boolean so repeated taps each fire. + */ + scrollTopNonce: number + /** + * Bumped when the active tab is re-tapped while a drill-in overlay is open — the topmost overlay pops + * to root (iOS "tap the active tab to pop the navigation stack"). A nonce so repeated taps back out level + * by level. + */ + overlayPopNonce: number + /** + * The Playlists tab's folder trail (root → deepest), lifted out of `PlaylistsView` so it survives both + * the tab-switch remount (`{#key activeTab}`) and — persisted — an app restart. The last entry is the + * folder currently shown; empty means the root level. + */ + playlistFolderTrail: string[] + /** + * One-shot boot anchor for the discovery feed's scroll restore: the release that was topmost when the + * app was last killed, plus the scroll offset within its row. The feed consumes it on its first mount + * (via `consumeDiscoveryAnchor`) once the release streams into the progressively loading list — + * anchoring by ID survives new releases shifting the list, unlike the raw `discoveryScrollTop`. + */ + discoveryRestoreAnchor: { releaseId: string; offset: number } | null +} + +const defaultState: MobileUIState = { + activeTab: 'discovery', + detailReleaseId: null, + detailCovering: false, + playerExpanded: false, + scrollTargetReleaseId: null, + discoveryScrollTop: 0, + tagFilterIds: [], + tagFilterMode: 'or', + selectMode: false, + selectedReleaseIds: new Set(), + addReleaseOpen: false, + openRowId: null, + settingsOpen: false, + settingsScrollTarget: null, + detailPlaylistId: null, + playlistDetailCovering: false, + playlistReorderMode: false, + detailTagId: null, + tagDetailCovering: false, + detailFollowSourceId: null, + followDetailCovering: false, + actionsReleaseId: null, + actionsContext: null, + actionsAnchorRect: null, + followReleaseId: null, + queueOrigin: null, + scrollTopNonce: 0, + overlayPopNonce: 0, + playlistFolderTrail: [], + discoveryRestoreAnchor: null, +} + +// --- Persisted navigation state (localStorage via shared/utils/storage) ------------------------- +// The persisted slice of the UI state: active tab, open detail overlays, the Playlists folder trail, +// and the discovery feed's scroll position (raw offset + release-ID anchor) — so a killed app reopens +// where the user left off. Ephemeral state (multi-select, expanded player, sheets, tag filters, nonces) +// intentionally resets. Stale IDs (entities deleted from another device) are validated and cleared +// after the stores load — see `navRestore.ts`. +const STORAGE_KEYS = { + activeTab: 'mobile.nav.activeTab', + detailReleaseId: 'mobile.nav.detailReleaseId', + detailPlaylistId: 'mobile.nav.detailPlaylistId', + detailTagId: 'mobile.nav.detailTagId', + detailFollowSourceId: 'mobile.nav.detailFollowSourceId', + playlistFolderTrail: 'mobile.nav.playlistFolderTrail', + scrollTop: 'mobile.discovery.scrollTop', + anchorReleaseId: 'mobile.discovery.anchorReleaseId', + anchorOffset: 'mobile.discovery.anchorOffset', +} as const + +/** Row height (px) of the discovery feed's release cards — the feed passes it to `ReleaseFeedList` and + * the scroll persistence derives the anchor row from it, so the two can't drift. */ +export const DISCOVERY_ROW_HEIGHT = 72 + +function readStoredId(key: string): string | null { + return getStoredString(key, '') || null +} + +/** Overlay kinds restored from storage at boot — each detail view consumes its marker once (via + * `consumeBootRestoredOverlay`) to skip the slide-in animation on the restored mount. */ +const bootRestoredOverlays = new Set<'release' | 'playlist' | 'tag' | 'follow'>() + +function seedInitialState(): MobileUIState { + const detailReleaseId = readStoredId(STORAGE_KEYS.detailReleaseId) + const detailPlaylistId = readStoredId(STORAGE_KEYS.detailPlaylistId) + const detailTagId = readStoredId(STORAGE_KEYS.detailTagId) + const detailFollowSourceId = readStoredId(STORAGE_KEYS.detailFollowSourceId) + if (detailReleaseId) bootRestoredOverlays.add('release') + if (detailPlaylistId) bootRestoredOverlays.add('playlist') + if (detailTagId) bootRestoredOverlays.add('tag') + if (detailFollowSourceId) bootRestoredOverlays.add('follow') + const anchorReleaseId = readStoredId(STORAGE_KEYS.anchorReleaseId) + return { + ...defaultState, + activeTab: getStoredString(STORAGE_KEYS.activeTab, 'discovery', [ + 'discovery', + 'following', + 'playlists', + 'tags', + ]), + detailReleaseId, + detailCovering: detailReleaseId !== null, + detailPlaylistId, + playlistDetailCovering: detailPlaylistId !== null, + detailTagId, + tagDetailCovering: detailTagId !== null, + detailFollowSourceId, + followDetailCovering: detailFollowSourceId !== null, + playlistFolderTrail: getStoredArray(STORAGE_KEYS.playlistFolderTrail), + discoveryScrollTop: getStoredNumber(STORAGE_KEYS.scrollTop, 0), + discoveryRestoreAnchor: anchorReleaseId + ? { releaseId: anchorReleaseId, offset: getStoredNumber(STORAGE_KEYS.anchorOffset, 0) } + : null, + } +} + +const initialState: MobileUIState = seedInitialState() + +// Debounced scroll persistence: `setDiscoveryScrollTop` fires on every (rAF-coalesced) scroll event, so +// the localStorage writes trail behind. The anchor is derived at write time from the displayed list: +// the release whose row spans the saved offset, plus the offset within that row. +let persistScrollTimer: ReturnType | null = null +let pendingScrollTop: number | null = null + +function persistDiscoveryScroll(top: number) { + // While a boot restore is still pending (anchor unconsumed), last session's stored values remain the + // truth — don't let pre-restore scroll events (often a spurious 0 at mount) wipe them before the feed + // has scrolled back. Persistence resumes once the feed consumes the anchor (or validation drops it). + if (get(mobileUIStore).discoveryRestoreAnchor !== null) return + setStoredNumber(STORAGE_KEYS.scrollTop, top) + const list = get(mobileDisplayedReleases) + const index = Math.min(list.length - 1, Math.floor(top / DISCOVERY_ROW_HEIGHT)) + const anchor = index >= 0 ? list[index] : undefined + setStoredString(STORAGE_KEYS.anchorReleaseId, anchor?.id ?? '') + setStoredNumber(STORAGE_KEYS.anchorOffset, anchor ? top - index * DISCOVERY_ROW_HEIGHT : 0) +} + +function schedulePersistDiscoveryScroll(top: number) { + pendingScrollTop = top + if (persistScrollTimer !== null) clearTimeout(persistScrollTimer) + persistScrollTimer = setTimeout(() => { + persistScrollTimer = null + if (pendingScrollTop !== null) persistDiscoveryScroll(pendingScrollTop) + pendingScrollTop = null + }, 300) +} + +function cancelPendingScrollPersist() { + if (persistScrollTimer !== null) clearTimeout(persistScrollTimer) + persistScrollTimer = null + pendingScrollTop = null +} + +/** Flush any pending (debounced) scroll persistence immediately — called when the app is backgrounded, + * so a backgrounded-then-killed app still keeps its very latest scroll position. */ +export function flushNavPersistence() { + if (persistScrollTimer === null) return + clearTimeout(persistScrollTimer) + persistScrollTimer = null + if (pendingScrollTop !== null) persistDiscoveryScroll(pendingScrollTop) + pendingScrollTop = null +} + +function createMobileUIStore() { + const { subscribe, set, update } = writable(initialState) + + return { + subscribe, + /** Switch the active bottom tab. No-op when already there, so navigating from both the pointerdown + * and the trailing click of a single touch tap (see TabBar) collapses to one state update. */ + setTab(tab: MobileTab) { + update((s) => (s.activeTab === tab ? s : { ...s, activeTab: tab })) + }, + /** + * Activate a bottom tab from the tab bar. Switching to a *different* tab just navigates. Re-tapping + * the *active* tab follows the iOS convention: pop the topmost drill-in to root, else exit multi-select, + * else scroll the view to the top. The nonces let the mounted view / overlay react (see TabBar and the + * tab views / detail overlays). Guard against the double fire of a touch tap in the TabBar, not here. + */ + activateTab(tab: MobileTab) { + update((s) => { + if (s.activeTab !== tab) return { ...s, activeTab: tab } + const hasOverlay = + s.detailReleaseId !== null || + s.detailPlaylistId !== null || + s.detailTagId !== null || + s.detailFollowSourceId !== null + if (hasOverlay) return { ...s, overlayPopNonce: s.overlayPopNonce + 1 } + if (s.selectMode) return { ...s, selectMode: false, selectedReleaseIds: new Set() } + return { ...s, scrollTopNonce: s.scrollTopNonce + 1 } + }) + }, + /** Push the release detail screen (a full-screen overlay layered above the active tab). Closes any + * swipe-open delete row so it isn't left revealed when the user returns to the feed. */ + openDetail(releaseId: string) { + update((s) => ({ ...s, detailReleaseId: releaseId, detailCovering: true, openRowId: null })) + }, + /** Begin closing the detail screen: drop the covering flag so the mini-player rises as it slides out, + * while leaving `detailReleaseId` set so the drawer stays mounted through its slide-out animation. */ + beginCloseDetail() { + update((s) => ({ ...s, detailCovering: false })) + }, + /** Finalize the close once the slide-out animation lands (clears the mount). */ + closeDetail() { + update((s) => ({ ...s, detailReleaseId: null, detailCovering: false })) + }, + /** Expand the mini-player to the full-screen player. */ + expandPlayer() { + update((s) => ({ ...s, playerExpanded: true })) + }, + collapsePlayer() { + update((s) => ({ ...s, playerExpanded: false })) + }, + /** + * Reveal the playing release in the discovery feed (desktop "locate" parity): switch to the + * discovery tab, collapse the full-screen player, ask the feed to scroll the release into view + * behind the overlay, and open its detail screen on top. The feed consumes `scrollTargetReleaseId` + * once it has scrolled. + */ + locateRelease(releaseId: string) { + update((s) => ({ + ...s, + activeTab: 'discovery', + playerExpanded: false, + detailReleaseId: releaseId, + detailCovering: true, + scrollTargetReleaseId: releaseId, + })) + }, + /** Clear the one-shot scroll target once the feed has scrolled to it. */ + consumeScrollTarget() { + update((s) => (s.scrollTargetReleaseId === null ? s : { ...s, scrollTargetReleaseId: null })) + }, + /** Remember the discovery feed's scroll offset so it survives the tab-switch remount. Also persists + * it (debounced, with the release-ID anchor) so it survives an app restart. */ + setDiscoveryScrollTop(top: number) { + update((s) => (s.discoveryScrollTop === top ? s : { ...s, discoveryScrollTop: top })) + schedulePersistDiscoveryScroll(top) + }, + /** Clear the one-shot boot scroll anchor once the feed has applied (or abandoned) it. */ + consumeDiscoveryAnchor() { + update((s) => (s.discoveryRestoreAnchor === null ? s : { ...s, discoveryRestoreAnchor: null })) + }, + + // --- Playlists folder trail (the Playlists tab's drill-down path) --------------------------- + /** Drill into a folder (append it to the trail). */ + pushPlaylistFolder(folderId: string) { + update((s) => ({ ...s, playlistFolderTrail: [...s.playlistFolderTrail, folderId] })) + }, + /** Back out one folder level. */ + popPlaylistFolder() { + update((s) => ({ ...s, playlistFolderTrail: s.playlistFolderTrail.slice(0, -1) })) + }, + /** Replace the trail wholesale (boot validation truncation, folder deleted mid-trail). */ + setPlaylistFolderTrail(trail: string[]) { + update((s) => ({ ...s, playlistFolderTrail: trail })) + }, + + /** Whether this overlay kind was restored from storage at boot — consumed once, so the restored + * mount skips its slide-in animation while later in-session opens animate normally. */ + consumeBootRestoredOverlay(kind: 'release' | 'playlist' | 'tag' | 'follow'): boolean { + return bootRestoredOverlays.delete(kind) + }, + + // --- Tag filtering (client-side over the loaded feed) --------------------------------------- + /** Add a tag to the feed filter, or remove it if already active. */ + toggleTagFilter(id: string) { + update((s) => ({ + ...s, + tagFilterIds: s.tagFilterIds.includes(id) + ? s.tagFilterIds.filter((tid) => tid !== id) + : [...s.tagFilterIds, id], + })) + }, + /** Flip the filter between AND (all selected tags) and OR (any). */ + toggleTagFilterMode() { + update((s) => ({ ...s, tagFilterMode: s.tagFilterMode === 'or' ? 'and' : 'or' })) + }, + /** Clear every active tag filter. */ + clearTagFilters() { + update((s) => (s.tagFilterIds.length === 0 ? s : { ...s, tagFilterIds: [] })) + }, + + // --- Multi-select ------------------------------------------------------------------------- + /** Enter multi-select mode, seeding the selection with the long-pressed release (one update, + * so the seed row is selected immediately with no flash). Closes any open swipe row. */ + enterSelectMode(seedId: string) { + update((s) => ({ ...s, selectMode: true, selectedReleaseIds: new Set([seedId]), openRowId: null })) + }, + /** Toggle a release's membership in the multi-select set. */ + toggleReleaseSelected(id: string) { + update((s) => { + const next = new Set(s.selectedReleaseIds) + if (next.has(id)) next.delete(id) + else next.add(id) + return { ...s, selectedReleaseIds: next } + }) + }, + /** Leave multi-select mode and drop the selection. */ + exitSelectMode() { + update((s) => ({ ...s, selectMode: false, selectedReleaseIds: new Set() })) + }, + + // --- Add release (entry point only; the functional modal is issue #56) ---------------------- + openAddRelease() { + update((s) => ({ ...s, addReleaseOpen: true })) + }, + closeAddRelease() { + update((s) => ({ ...s, addReleaseOpen: false })) + }, + + // --- Settings drawer (right-side overlay; mirrors the release detail mount pattern) ------------ + /** Open the settings drawer, optionally requesting a scroll to a named section (e.g. 'sync'). + * Closes any swipe-open delete row so it isn't left revealed behind the overlay. */ + openSettings(section?: string) { + update((s) => ({ + ...s, + settingsOpen: true, + settingsScrollTarget: section ?? null, + openRowId: null, + })) + }, + /** Finalize the close once the slide-out animation lands (clears the mount). */ + closeSettings() { + update((s) => ({ ...s, settingsOpen: false })) + }, + /** Clear the one-shot settings scroll target once the view has scrolled to it. */ + consumeSettingsScrollTarget() { + update((s) => (s.settingsScrollTarget === null ? s : { ...s, settingsScrollTarget: null })) + }, + + // --- Playlist detail overlay (mirrors release detail pattern) --------------------------------- + openPlaylist(playlistId: string) { + update((s) => ({ + ...s, + detailPlaylistId: playlistId, + playlistDetailCovering: true, + playlistReorderMode: false, + selectMode: false, + selectedReleaseIds: new Set(), + openRowId: null, + })) + }, + beginClosePlaylist() { + update((s) => ({ ...s, playlistDetailCovering: false, playlistReorderMode: false })) + }, + closePlaylist() { + update((s) => ({ + ...s, + detailPlaylistId: null, + playlistDetailCovering: false, + playlistReorderMode: false, + })) + }, + toggleReorderMode() { + update((s) => ({ ...s, playlistReorderMode: !s.playlistReorderMode })) + }, + exitReorderMode() { + update((s) => ({ ...s, playlistReorderMode: false })) + }, + + // --- Tag detail overlay (its filtered release feed; mirrors the playlist detail pattern) ------- + /** Open the tag detail screen — a full-screen feed of the releases carrying the tag. Drops any + * active multi-select / open swipe row so it isn't left dangling behind the overlay. */ + openTag(tagId: string) { + update((s) => ({ + ...s, + detailTagId: tagId, + tagDetailCovering: true, + selectMode: false, + selectedReleaseIds: new Set(), + openRowId: null, + })) + }, + /** Begin closing the tag detail (drop the covering flag so the mini-player rises as it slides out). */ + beginCloseTag() { + update((s) => ({ ...s, tagDetailCovering: false })) + }, + /** Finalize the close once the slide-out animation lands. */ + closeTag() { + update((s) => ({ ...s, detailTagId: null, tagDetailCovering: false })) + }, + + // --- Follow source detail overlay (a followed artist/label's releases; mirrors the tag detail) - + /** Open the follow-source detail screen — a full-screen feed of the releases from this artist/label. + * Drops any active multi-select / open swipe row so it isn't left dangling behind the overlay. */ + openFollowSource(sourceId: string) { + update((s) => ({ + ...s, + detailFollowSourceId: sourceId, + followDetailCovering: true, + selectMode: false, + selectedReleaseIds: new Set(), + openRowId: null, + })) + }, + /** Begin closing the follow-source detail (drop the covering flag as it slides out). */ + beginCloseFollowSource() { + update((s) => ({ ...s, followDetailCovering: false })) + }, + /** Finalize the close once the slide-out animation lands. */ + closeFollowSource() { + update((s) => ({ ...s, detailFollowSourceId: null, followDetailCovering: false })) + }, + + // --- Release context menu --------------------------------------------------------------------- + openActionsSheet( + releaseId: string, + context: 'feed' | 'playlist' | 'tag' | 'follow', + anchorRect: { top: number; left: number; width: number; height: number } | null + ) { + update((s) => ({ + ...s, + actionsReleaseId: releaseId, + actionsContext: context, + actionsAnchorRect: anchorRect, + openRowId: null, + })) + }, + closeActionsSheet() { + update((s) => ({ ...s, actionsReleaseId: null, actionsContext: null, actionsAnchorRect: null })) + }, + + // --- Inline follow sheet (follow a release's artist / label) ---------------------------------- + /** Open the follow-artist/label sheet for a release (from its context menu's "Follow" action). */ + openFollowSheet(releaseId: string) { + update((s) => ({ ...s, followReleaseId: releaseId })) + }, + closeFollowSheet() { + update((s) => ({ ...s, followReleaseId: null })) + }, + + // --- Playback context origin ------------------------------------------------------------------ + /** Record where the active preview session was started from (set when playback begins), so the feed's + * live filter only re-scopes a discovery-origin queue. Pass null when playback stops. */ + setQueueOrigin(origin: PlaybackContextOrigin | null) { + update((s) => (s.queueOrigin === origin ? s : { ...s, queueOrigin: origin })) + }, + + // --- Swipe-to-delete single-open invariant -------------------------------------------------- + /** Record which row's delete action is revealed; opening one row closes any other. Pass null to + * close the open row (e.g. on scroll). */ + setOpenRow(id: string | null) { + update((s) => (s.openRowId === id ? s : { ...s, openRowId: id })) + }, + + /** Back to a pristine state (NOT the storage-seeded boot state), wiping the persisted keys too. */ + reset() { + cancelPendingScrollPersist() + for (const key of Object.values(STORAGE_KEYS)) removeStored(key) + set(defaultState) + }, + } +} + +export const mobileUIStore = createMobileUIStore() + +// Persist the navigation slice on change. One targeted diff-subscribe (rather than a write in every +// setter) so no transition — present or future — can forget to persist; the diff touches only these +// six fields, so the Set-valued ephemeral state costs nothing. The scroll offset/anchor persist +// separately (debounced) from `setDiscoveryScrollTop`. +let prevPersisted = initialState +mobileUIStore.subscribe((s) => { + if (s.activeTab !== prevPersisted.activeTab) setStoredString(STORAGE_KEYS.activeTab, s.activeTab) + if (s.detailReleaseId !== prevPersisted.detailReleaseId) + setStoredString(STORAGE_KEYS.detailReleaseId, s.detailReleaseId ?? '') + if (s.detailPlaylistId !== prevPersisted.detailPlaylistId) + setStoredString(STORAGE_KEYS.detailPlaylistId, s.detailPlaylistId ?? '') + if (s.detailTagId !== prevPersisted.detailTagId) setStoredString(STORAGE_KEYS.detailTagId, s.detailTagId ?? '') + if (s.detailFollowSourceId !== prevPersisted.detailFollowSourceId) + setStoredString(STORAGE_KEYS.detailFollowSourceId, s.detailFollowSourceId ?? '') + if (s.playlistFolderTrail !== prevPersisted.playlistFolderTrail) + setStoredArray(STORAGE_KEYS.playlistFolderTrail, s.playlistFolderTrail) + prevPersisted = s +}) + +export const activeTab = derived(mobileUIStore, ($s) => $s.activeTab) +export const detailReleaseId = derived(mobileUIStore, ($s) => $s.detailReleaseId) +export const detailCovering = derived(mobileUIStore, ($s) => $s.detailCovering) +export const isPlayerExpanded = derived(mobileUIStore, ($s) => $s.playerExpanded) +export const scrollTargetReleaseId = derived(mobileUIStore, ($s) => $s.scrollTargetReleaseId) +export const settingsOpen = derived(mobileUIStore, ($s) => $s.settingsOpen) +export const settingsScrollTarget = derived(mobileUIStore, ($s) => $s.settingsScrollTarget) +export const tagFilterIds = derived(mobileUIStore, ($s) => $s.tagFilterIds) +export const tagFilterMode = derived(mobileUIStore, ($s) => $s.tagFilterMode) + +/** Client-side tag filter over the loaded feed (AND = all selected tags, OR = any). */ +function applyTagFilter(list: DiscoveryRelease[], ids: string[], mode: TagFilterMode): DiscoveryRelease[] { + if (ids.length === 0) return list + const set = new Set(ids) + return mode === 'and' + ? list.filter((r) => ids.every((id) => r.tags.some((t) => t.id === id))) + : list.filter((r) => r.tags.some((t) => set.has(t.id))) +} + +/** + * The discovery feed's displayed list: the shared `sortedReleases` (search + liked/new + sort) with the + * mobile-only tag filter applied. Single source of truth for both the rendered feed and the playback + * queue captured when a preview starts — so "play / shuffle the whole list" spans exactly what's on screen. + */ +export const mobileDisplayedReleases = derived( + [sortedReleases, tagFilterIds, tagFilterMode], + ([$sorted, $ids, $mode]) => applyTagFilter($sorted, $ids, $mode) +) + +/** + * The release list + origin a preview started *right now* would use as its playback context: the topmost + * open overlay's list (follow / tag / playlist detail), or — with no detail open — the discovery feed's + * on-screen list. Read once at play time (`ReleaseDetail`) so next / shuffle scope to the view the user is + * in. The three detail overlays are mutually exclusive, so the precedence order here never conflicts. + */ +export const activePlaybackContext = derived( + [mobileUIStore, discoveryStore, discoveryPlaylistReleases, followedSources, mobileDisplayedReleases], + ([$ui, $disc, $playlistReleases, $follows, $displayed]): { + origin: PlaybackContextOrigin + releases: DiscoveryRelease[] + } => { + if ($ui.detailFollowSourceId) { + const source = $follows.find((s) => s.id === $ui.detailFollowSourceId) + if (source) return { origin: 'follow', releases: releasesFromSource($disc.releases, source.url) } + } + if ($ui.detailTagId) { + const tagId = $ui.detailTagId + return { origin: 'tag', releases: $disc.releases.filter((r) => r.tags.some((t) => t.id === tagId)) } + } + if ($ui.detailPlaylistId) return { origin: 'playlist', releases: $playlistReleases } + return { origin: 'discovery', releases: $displayed } + } +) +export const selectMode = derived(mobileUIStore, ($s) => $s.selectMode) +export const selectedReleaseIds = derived(mobileUIStore, ($s) => $s.selectedReleaseIds) +export const selectedReleaseCount = derived(mobileUIStore, ($s) => $s.selectedReleaseIds.size) +export const addReleaseOpen = derived(mobileUIStore, ($s) => $s.addReleaseOpen) +export const openRowId = derived(mobileUIStore, ($s) => $s.openRowId) +export const detailPlaylistId = derived(mobileUIStore, ($s) => $s.detailPlaylistId) +export const playlistDetailCovering = derived(mobileUIStore, ($s) => $s.playlistDetailCovering) +export const playlistReorderMode = derived(mobileUIStore, ($s) => $s.playlistReorderMode) +export const detailTagId = derived(mobileUIStore, ($s) => $s.detailTagId) +export const tagDetailCovering = derived(mobileUIStore, ($s) => $s.tagDetailCovering) +export const detailFollowSourceId = derived(mobileUIStore, ($s) => $s.detailFollowSourceId) +export const followDetailCovering = derived(mobileUIStore, ($s) => $s.followDetailCovering) +export const actionsReleaseId = derived(mobileUIStore, ($s) => $s.actionsReleaseId) +export const actionsContext = derived(mobileUIStore, ($s) => $s.actionsContext) +export const actionsAnchorRect = derived(mobileUIStore, ($s) => $s.actionsAnchorRect) +export const followReleaseId = derived(mobileUIStore, ($s) => $s.followReleaseId) +export const queueOrigin = derived(mobileUIStore, ($s) => $s.queueOrigin) +export const scrollTopNonce = derived(mobileUIStore, ($s) => $s.scrollTopNonce) +export const overlayPopNonce = derived(mobileUIStore, ($s) => $s.overlayPopNonce) +export const playlistFolderTrail = derived(mobileUIStore, ($s) => $s.playlistFolderTrail) diff --git a/apps/mobile/src/lib/stores/navRestore.ts b/apps/mobile/src/lib/stores/navRestore.ts new file mode 100644 index 00000000..f87c4340 --- /dev/null +++ b/apps/mobile/src/lib/stores/navRestore.ts @@ -0,0 +1,109 @@ +import { get, type Readable } from 'svelte/store' +import { discoveryStore } from '$shared/stores/discovery' +import { discoveryPlaylistReleases } from '$shared/stores/discoveryPlaylist' +import { followStore } from '$shared/stores/follow' +import { playlistsStore } from '$shared/stores/playlists' +import { tagsStore } from '$shared/stores/tags' +import { mobileUIStore } from './mobileUI' + +/** + * Boot validation for the persisted navigation state (see `mobileUI.ts`): the restored folder trail / + * detail-overlay IDs / scroll anchor may reference entities deleted since the last session (removed on + * another device and cloud-synced away). Once the owning stores load, stale references are silently + * cleared — the trail truncates at the first missing folder, a stale detail overlay closes back to its + * tab, and a stale scroll anchor is dropped so the feed falls back to its raw offset. + * + * Called fire-and-forget from `+page.svelte`'s `onMount`, which runs AFTER the tab views' `onMount` + * loads have synchronously flipped their store's `loading` flag — so "already loading" reliably means + * the active tab kicked the load off and we only need to wait for it. + */ +export async function validateRestoredNavigation(): Promise { + const ui = get(mobileUIStore) + const tasks: Promise[] = [] + + if (ui.playlistFolderTrail.length > 0 || ui.detailPlaylistId !== null) { + tasks.push(waitUntilLoaded(playlistsStore, () => playlistsStore.load()).then(validatePlaylists)) + } + if (ui.detailTagId !== null) { + tasks.push(waitUntilLoaded(tagsStore, () => tagsStore.load()).then(validateTag)) + } + if (ui.detailFollowSourceId !== null) { + tasks.push(waitUntilLoaded(followStore, () => followStore.load()).then(validateFollowSource)) + } + if (ui.detailReleaseId !== null) { + tasks.push(waitForDiscoveryLoaded().then(validateRelease)) + } + + await Promise.all(tasks) +} + +/** Wait for a lazily-loaded store: if its view already started the load, wait for it to finish; + * otherwise (the store's tab isn't active this boot) run the load ourselves. */ +function waitUntilLoaded(store: Readable<{ loading: boolean }>, load: () => Promise): Promise { + if (!get(store).loading) return load().then(() => undefined) + return whenNotLoading(store) +} + +function whenNotLoading(store: Readable<{ loading: boolean }>): Promise { + return new Promise((resolve) => { + // The microtask defers past the synchronous first emission, so `unsubscribe` is always assigned + // by the time it runs (subscribe callbacks fire immediately in Svelte stores). + const unsubscribe = store.subscribe((s) => { + if (s.loading) return + queueMicrotask(() => { + unsubscribe() + resolve() + }) + }) + }) +} + +/** Discovery streams the whole collection through one `loadReleases()` — only start it if nothing has + * (neither the Discovery view nor a detail view that self-loads); a duplicate call would needlessly + * cancel and restart the in-flight load via its generation token. */ +async function waitForDiscoveryLoaded(): Promise { + const s = get(discoveryStore) + if (s.releases.length === 0 && !s.loading) { + await discoveryStore.loadReleases() + return + } + if (get(discoveryStore).loading) await whenNotLoading(discoveryStore) +} + +function validatePlaylists(): void { + const playlists = get(playlistsStore).playlists.filter((p) => p.context === 'discovery') + const trail = get(mobileUIStore).playlistFolderTrail + const firstMissing = trail.findIndex((id) => !playlists.some((p) => p.id === id && p.is_folder)) + if (firstMissing !== -1) mobileUIStore.setPlaylistFolderTrail(trail.slice(0, firstMissing)) + const playlistId = get(mobileUIStore).detailPlaylistId + if (playlistId !== null && !playlists.some((p) => p.id === playlistId)) mobileUIStore.closePlaylist() +} + +function validateTag(): void { + const tagId = get(mobileUIStore).detailTagId + if (tagId === null) return + const exists = get(tagsStore).categories.some((c) => c.tags.some((t) => t.id === tagId)) + if (!exists) mobileUIStore.closeTag() +} + +function validateFollowSource(): void { + const sourceId = get(mobileUIStore).detailFollowSourceId + if (sourceId === null) return + if (!get(followStore).sources.some((s) => s.id === sourceId)) mobileUIStore.closeFollowSource() +} + +function validateRelease(): void { + const state = get(mobileUIStore) + if (state.detailReleaseId !== null) { + const id = state.detailReleaseId + const exists = + get(discoveryStore).releases.some((r) => r.id === id) || get(discoveryPlaylistReleases).some((r) => r.id === id) + if (!exists) mobileUIStore.closeDetail() + } + // Opportunistic: with the full collection loaded anyway, drop a stale scroll anchor here so a later + // Discovery visit doesn't wait for it (the Discovery view's own fallback covers the on-tab case). + const anchor = get(mobileUIStore).discoveryRestoreAnchor + if (anchor && !get(discoveryStore).releases.some((r) => r.id === anchor.releaseId)) { + mobileUIStore.consumeDiscoveryAnchor() + } +} diff --git a/apps/mobile/src/lib/stores/onboarding.ts b/apps/mobile/src/lib/stores/onboarding.ts new file mode 100644 index 00000000..2303fd71 --- /dev/null +++ b/apps/mobile/src/lib/stores/onboarding.ts @@ -0,0 +1,24 @@ +import { writable } from 'svelte/store' +import { getStoredBoolean, setStoredBoolean } from '$shared/utils/storage' + +// First-run onboarding is gated on a DEVICE-LOCAL localStorage flag — deliberately NOT the shared +// `hasCompletedOnboarding` setting, which is desktop-oriented and cloud-syncs via `Bucket::Settings`. +// Reusing that would suppress mobile onboarding for anyone who onboarded on desktop, whereas the mobile +// first-run flow (add-release / preview / optional sign-in) is distinct and per-device. +const STORAGE_KEY = 'mobile-onboarding-complete' + +function createOnboardingStore() { + // Seed from storage so a returning user never sees the carousel again (and boot doesn't flash it). + const { subscribe, set } = writable(getStoredBoolean(STORAGE_KEY, false)) + + return { + subscribe, + // Mark onboarding done — persists across launches and flips the overlay off reactively. + complete() { + setStoredBoolean(STORAGE_KEY, true) + set(true) + }, + } +} + +export const onboardingComplete = createOnboardingStore() diff --git a/apps/mobile/src/lib/stores/pendingReleases.ts b/apps/mobile/src/lib/stores/pendingReleases.ts new file mode 100644 index 00000000..e117c8b9 --- /dev/null +++ b/apps/mobile/src/lib/stores/pendingReleases.ts @@ -0,0 +1,228 @@ +import { writable, derived } from 'svelte/store' +import { getStoredString, setStoredString } from '$shared/utils/storage' +import { detectSourceType } from '$shared/utils/discoveryLinks' +import { discoveryStore } from '$shared/stores/discovery' +import * as discoveryApi from '$shared/api/discovery' +import type { DiscoverySourceType, DiscoveryReleaseCreate } from '$shared/types' + +const STORAGE_KEY = 'discovery.pendingQueue' + +// Exponential backoff for metadata-fetch retries, mirroring the follow watch loop +// (src-tauri/src/services/follow/watch.rs): 5 min base, doubling per attempt, capped at 6 h. A +// failed URL retries on this schedule (and immediately when connectivity returns) rather than +// stalling until the app restarts. +const BACKOFF_BASE_MS = 5 * 60 * 1000 +const BACKOFF_MAX_MS = 6 * 60 * 60 * 1000 + +function backoffMs(attempts: number): number { + const shift = Math.min(Math.max(attempts - 1, 0), 10) + return Math.min(BACKOFF_BASE_MS * 2 ** shift, BACKOFF_MAX_MS) +} + +export type PendingStatus = 'queued' | 'fetching' | 'failed' + +export interface PendingRelease { + id: string + url: string + sourceType: DiscoverySourceType + addedAt: number + status: PendingStatus + /** Number of failed fetch attempts (drives the backoff schedule). */ + attempts: number + /** Epoch ms before which this item should not be retried (0 = eligible now). */ + nextRetryAt: number +} + +interface PendingState { + items: PendingRelease[] + processing: boolean +} + +let nextId = 0 +function genId(): string { + return `pending-${Date.now()}-${nextId++}` +} + +let retryTimer: ReturnType | null = null + +const { subscribe, set, update } = writable({ items: [], processing: false }) + +function persist(items: PendingRelease[]) { + const serializable = items.map((p) => ({ + id: p.id, + url: p.url, + sourceType: p.sourceType, + addedAt: p.addedAt, + attempts: p.attempts, + nextRetryAt: p.nextRetryAt, + })) + setStoredString(STORAGE_KEY, JSON.stringify(serializable)) +} + +function enqueue(url: string) { + update((s) => { + if (s.items.some((p) => p.url === url)) return s + const item: PendingRelease = { + id: genId(), + url, + sourceType: detectSourceType(url), + addedAt: Date.now(), + status: 'queued', + attempts: 0, + nextRetryAt: 0, + } + const items = [...s.items, item] + persist(items) + return { ...s, items } + }) +} + +function remove(id: string) { + update((s) => { + const items = s.items.filter((p) => p.id !== id) + persist(items) + return { ...s, items } + }) +} + +/** + * Schedule the next retry sweep for the soonest item still in backoff, so failed URLs retry on + * their own timer without needing an `online` event or an app restart. Replaces any pending timer. + */ +function scheduleNextRetry(items: PendingRelease[]) { + if (retryTimer) { + clearTimeout(retryTimer) + retryTimer = null + } + const now = Date.now() + const soonest = items + .filter((p) => p.status === 'failed' && p.nextRetryAt > now) + .reduce((min, p) => Math.min(min, p.nextRetryAt), Number.POSITIVE_INFINITY) + if (!Number.isFinite(soonest)) return + // Cap the delay so a very large backoff still schedules a bounded timer. + const delay = Math.min(Math.max(soonest - now, 0), BACKOFF_MAX_MS) + retryTimer = setTimeout(() => void processQueue(), delay) +} + +async function processQueue() { + let state: PendingState | undefined + const unsub = subscribe((s) => (state = s)) + unsub() + + if (!state || state.processing || state.items.length === 0) return + if (!navigator.onLine) return + + update((s) => ({ ...s, processing: true })) + + // Eligible = queued, or failed whose backoff window has elapsed. Items still in backoff are + // left for the retry timer (or the next `online` event). + const now = Date.now() + const eligible = state.items.filter((p) => p.status === 'queued' || (p.status === 'failed' && p.nextRetryAt <= now)) + for (const pending of eligible) { + update((s) => ({ + ...s, + items: s.items.map((p) => (p.id === pending.id ? { ...p, status: 'fetching' as PendingStatus } : p)), + })) + + try { + const metadata = await discoveryApi.fetchMetadata(pending.url) + const create: DiscoveryReleaseCreate = { + url: pending.url, + source_type: (metadata.source_type as DiscoverySourceType) || pending.sourceType, + } + if (metadata.artist) create.artist = metadata.artist + if (metadata.title) create.title = metadata.title + if (metadata.label) create.label = metadata.label + if (metadata.release_date) create.release_date = metadata.release_date + if (metadata.artwork_url) create.artwork_url = metadata.artwork_url + if (metadata.parent_url) create.parent_url = metadata.parent_url + if (metadata.tracks.length > 0) { + create.tracks = metadata.tracks.map((t) => ({ + name: t.name, + position: t.position, + duration_ms: t.duration_ms ?? undefined, + video_id: t.video_id ?? undefined, + })) + } + + await discoveryStore.createRelease(create) + remove(pending.id) + } catch { + // Bump the attempt count and push the next retry out on the backoff schedule. + update((s) => { + const items = s.items.map((p) => { + if (p.id !== pending.id) return p + const attempts = p.attempts + 1 + return { + ...p, + status: 'failed' as PendingStatus, + attempts, + nextRetryAt: Date.now() + backoffMs(attempts), + } + }) + persist(items) + return { ...s, items } + }) + } + } + + let latest: PendingState | undefined + const unsub2 = subscribe((s) => (latest = s)) + unsub2() + update((s) => ({ ...s, processing: false })) + if (latest) scheduleNextRetry(latest.items) +} + +function hydrate() { + const raw = getStoredString(STORAGE_KEY, '') + if (!raw) return + try { + const parsed = JSON.parse(raw) as Array<{ + id: string + url: string + sourceType: DiscoverySourceType + addedAt: number + attempts?: number + nextRetryAt?: number + }> + const items: PendingRelease[] = parsed.map((p) => { + const attempts = p.attempts ?? 0 + const nextRetryAt = p.nextRetryAt ?? 0 + // Preserve backoff across restarts: an item still inside its window stays 'failed' so the + // retry timer picks it up, rather than being retried immediately on boot. + const status: PendingStatus = attempts > 0 && nextRetryAt > Date.now() ? 'failed' : 'queued' + return { + id: p.id || genId(), + url: p.url, + sourceType: p.sourceType, + addedAt: p.addedAt, + status, + attempts, + nextRetryAt, + } + }) + set({ items, processing: false }) + scheduleNextRetry(items) + } catch { + // Corrupt data — start fresh + } +} + +let listenersAttached = false +function attachNetworkListeners() { + if (listenersAttached) return + listenersAttached = true + window.addEventListener('online', () => void processQueue()) +} + +export const pendingReleasesStore = { + subscribe, + enqueue, + remove, + processQueue, + hydrate, + attachNetworkListeners, +} + +export const pendingReleases = derived(pendingReleasesStore, ($s) => $s.items) +export const hasPendingReleases = derived(pendingReleasesStore, ($s) => $s.items.length > 0) diff --git a/apps/mobile/src/lib/stores/playlistCovers.ts b/apps/mobile/src/lib/stores/playlistCovers.ts new file mode 100644 index 00000000..793119ae --- /dev/null +++ b/apps/mobile/src/lib/stores/playlistCovers.ts @@ -0,0 +1,43 @@ +import { SvelteMap } from 'svelte/reactivity' +import { playlistsStore } from '$shared/stores/playlists' + +// Cache of playlist id → up to 4 distinct cover URLs, for the mosaic thumbnails in PlaylistsView. +// Module-level so it survives the view's remount-on-tab-return; a SvelteMap so rows update reactively +// when covers arrive. An entry of `[]` is a real cached result ("this playlist has no covers"), not a +// miss — it stops us refetching playlists that legitimately have no artwork. +const covers = new SvelteMap() + +// Ids with a fetch in flight, so overlapping `ensure` calls don't double-request. +const inFlight = new Set() + +/** Reactive read of a playlist's cached covers (empty array until loaded / when none). */ +export function getPlaylistCovers(playlistId: string): string[] { + return covers.get(playlistId) ?? [] +} + +/** Batch-fetch covers for any of the given playlists not already cached or in flight. */ +export async function ensurePlaylistCovers(playlistIds: string[]): Promise { + const missing = playlistIds.filter((id) => !covers.has(id) && !inFlight.has(id)) + if (missing.length === 0) return + for (const id of missing) inFlight.add(id) + try { + const results = await playlistsStore.getPlaylistCoverArt(missing) + for (const r of results) covers.set(r.playlist_id, r.artwork_urls) + // The backend echoes every requested id, but guard against a partial result so a failed + // fetch caches `[]` rather than retrying forever. + for (const id of missing) if (!covers.has(id)) covers.set(id, []) + } finally { + for (const id of missing) inFlight.delete(id) + } +} + +/** + * Re-fetch one playlist's covers and update the cache in place. Call after its releases change + * (add / remove / reorder) so the thumbnail reflects the edit immediately — unlike a plain delete, + * this never leaves the row showing a placeholder while waiting for the next list refresh. + */ +export async function refreshPlaylistCovers(playlistId: string): Promise { + const results = await playlistsStore.getPlaylistCoverArt([playlistId]) + const match = results.find((r) => r.playlist_id === playlistId) + covers.set(playlistId, match?.artwork_urls ?? []) +} diff --git a/apps/mobile/src/lib/stores/splash.ts b/apps/mobile/src/lib/stores/splash.ts new file mode 100644 index 00000000..f73f7489 --- /dev/null +++ b/apps/mobile/src/lib/stores/splash.ts @@ -0,0 +1,10 @@ +import { writable } from 'svelte/store' + +// Whether the launch splash is showing. Starts true so the Svelte splash is up the moment the app +// mounts (taking over from the pre-paint splash in app.html); the layout calls dismissSplash() once +// i18n + persisted settings have loaded. Mirrors the desktop splash store. +export const splashVisible = writable(true) + +export function dismissSplash() { + splashVisible.set(false) +} diff --git a/apps/mobile/src/lib/utils/dialog.ts b/apps/mobile/src/lib/utils/dialog.ts new file mode 100644 index 00000000..62476918 --- /dev/null +++ b/apps/mobile/src/lib/utils/dialog.ts @@ -0,0 +1,35 @@ +import { confirm } from '@tauri-apps/plugin-dialog' +import { get } from 'svelte/store' +import { translate } from '$shared/i18n' + +export type ConfirmDialogOptions = { + /** Dialog title — usually the question itself (e.g. "Delete release?"). Defaults to the app name. */ + title?: string + /** Affirmative button label. Names the action, so it's always explicit (e.g. Delete, Remove, Discard). */ + confirmLabel: string + /** Dismiss button label. Defaults to the localized `common.cancel`. */ + cancelLabel?: string + /** Platform severity hint (desktop shows a matching icon). Defaults to `warning` for destructive prompts. */ + kind?: 'info' | 'warning' | 'error' +} + +/** + * Native OS confirm dialog (iOS UIAlertController / Android AlertDialog) via the Tauri dialog plugin. + * Mobile prefers these over hand-built web modals for simple confirms: they match the platform, own their + * own animation + accessibility, and need no custom UI. Resolves true only when the user taps the + * affirmative button. Labels come from app i18n (not the OS) so the whole dialog stays in the app's + * language. Fails closed — if the dialog can't be shown, resolves false so a destructive caller never + * proceeds on its own. + */ +export async function confirmDialog(message: string, options: ConfirmDialogOptions): Promise { + try { + return await confirm(message, { + title: options.title, + kind: options.kind ?? 'warning', + okLabel: options.confirmLabel, + cancelLabel: options.cancelLabel ?? get(translate)('common.cancel'), + }) + } catch { + return false + } +} diff --git a/apps/mobile/src/lib/utils/haptics.ts b/apps/mobile/src/lib/utils/haptics.ts new file mode 100644 index 00000000..51066594 --- /dev/null +++ b/apps/mobile/src/lib/utils/haptics.ts @@ -0,0 +1,35 @@ +import { impactFeedback } from '@tauri-apps/plugin-haptics' + +/** + * Fire a light haptic tap to acknowledge a touch (e.g. tapping a track to play). Best-effort: the + * haptics plugin is mobile-only and can be unavailable (simulator, denied permission, older device), + * so any failure is swallowed — a missing buzz must never break the interaction. + */ +export async function lightTap(): Promise { + try { + await impactFeedback('light') + } catch { + // Haptics unavailable — ignore. + } +} + +/** A medium impact — a firmer acknowledgement than {@link lightTap} (e.g. committing a selection). */ +export async function mediumTap(): Promise { + try { + await impactFeedback('medium') + } catch { + // Haptics unavailable — ignore. + } +} + +/** + * A sharp, rigid impact — the closest match to iOS's context-menu "thump" when a long-press commits. + * Fired the moment a context menu opens. Best-effort like the others. + */ +export async function rigidTap(): Promise { + try { + await impactFeedback('rigid') + } catch { + // Haptics unavailable — ignore. + } +} diff --git a/apps/mobile/src/routes/+layout.svelte b/apps/mobile/src/routes/+layout.svelte new file mode 100644 index 00000000..7f2470a2 --- /dev/null +++ b/apps/mobile/src/routes/+layout.svelte @@ -0,0 +1,168 @@ + + + + +{#if i18nReady} + {@render children()} +{/if} + + + diff --git a/apps/mobile/src/routes/+layout.ts b/apps/mobile/src/routes/+layout.ts new file mode 100644 index 00000000..bef36871 --- /dev/null +++ b/apps/mobile/src/routes/+layout.ts @@ -0,0 +1,3 @@ +// SPA mode — no SSR/prerendering (mirrors the desktop app; required for adapter-static fallback). +export const ssr = false +export const prerender = false diff --git a/apps/mobile/src/routes/+page.svelte b/apps/mobile/src/routes/+page.svelte new file mode 100644 index 00000000..089d0e84 --- /dev/null +++ b/apps/mobile/src/routes/+page.svelte @@ -0,0 +1,135 @@ + + + + +{#if detailRelease} + +{/if} + +{#if detailPlaylist} + +{/if} + +{#if detailTagInfo} + +{/if} + +{#if detailFollowSource} + +{/if} + + + mobileUIStore.closeFollowSheet()} /> + + +{#if $settingsOpen} + +{/if} + + + + + +{#if !$onboardingComplete} + +{/if} diff --git a/apps/mobile/src/style.css b/apps/mobile/src/style.css new file mode 100644 index 00000000..be7e6fcc --- /dev/null +++ b/apps/mobile/src/style.css @@ -0,0 +1,185 @@ +/* Mobile serves NO web font: the default `data-font` is the native system stack (San Francisco on + iOS), so the shell paints instantly and works offline. The other `data-font` families stay defined + in the shared theme but fall back to the system font here (nothing is fetched) until a mobile font + picker ships. */ +@import 'tailwindcss'; + +/* Shared cross-platform Svelte components (e.g. Slider) live outside this app's Vite root, so Tailwind's + automatic content detection won't see them — register the directory explicitly to generate their classes. */ +@source '../../../shared/components'; + +/* Cross-platform theme tokens shared with the desktop app: the dark/light theme, accent, and font + CSS variables that the @theme mapping and brand utilities below reference. */ +@import '../../../shared/styles/theme.css'; + +/* ============================================================================= + Theme Definition with Tailwind CSS 4 @theme (mirrors desktop's mapping so the same + surface, text, and stroke utilities are available on mobile). + ============================================================================= */ + +@theme { + --color-surface-0: var(--surface-0); + --color-surface-1: var(--surface-1); + --color-surface-2: var(--surface-2); + + --color-text-primary: var(--text-primary); + --color-text-secondary: var(--text-secondary); + --color-text-tertiary: var(--text-tertiary); + + --color-stroke: var(--stroke); + --color-stroke-subtle: var(--stroke-subtle); + + --color-danger: #ef4444; + --color-warning: #f59e0b; + --color-success: #22c55e; + --color-info: #3b82f6; + + /* Conventional iOS-sheet easing for drawers/sheets/panels; mirrored in JS by `easeFluid` + (lib/easing.ts) so the CSS- and Svelte-driven transitions share one smooth curve. */ + --ease-fluid: cubic-bezier(0.32, 0.72, 0, 1); + + /* Slight-overshoot "spring" for the iOS context-menu platter pop (the y2 of 1.56 gives ~6% + overshoot). Used only on the platter's scale/opacity entrance — the lifted preview stays on + --ease-fluid, since a lifting object that overshoots looks wrong. */ + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); +} + +/* ============================================================================= + Brand Color Utilities (raw var() refs so they react to data-accent changes). + ============================================================================= */ + +@utility text-brand-primary { + color: var(--brand-primary); +} + +@utility bg-brand-primary { + background-color: var(--brand-primary); +} + +@utility bg-brand-hover { + background-color: var(--brand-hover); +} + +@utility bg-brand-muted { + background-color: var(--brand-muted); +} + +@utility border-brand-primary { + border-color: var(--brand-primary); +} + +/* ============================================================================= + Liquid-glass surface (iOS-style): a translucent, blurred, slightly saturated + material that lets content scroll through behind it. Theme-aware via the + surface token, with both -webkit- and standard backdrop-filter for WKWebView. + ============================================================================= */ + +@utility glass { + background-color: color-mix(in srgb, var(--surface-1) 72%, transparent); + -webkit-backdrop-filter: blur(24px) saturate(180%); + backdrop-filter: blur(24px) saturate(180%); +} + +@utility glass-strong { + background-color: color-mix(in srgb, var(--surface-1) 85%, transparent); + -webkit-backdrop-filter: blur(32px) saturate(180%); + backdrop-filter: blur(32px) saturate(180%); +} + +/* ============================================================================= + Safe-area utilities (iOS notch / Dynamic Island / home indicator). + Require `viewport-fit=cover` (set in app.html). + ============================================================================= */ + +@utility pt-safe { + padding-top: env(safe-area-inset-top); +} + +@utility pb-safe { + padding-bottom: env(safe-area-inset-bottom); +} + +@utility pl-safe { + padding-left: env(safe-area-inset-left); +} + +@utility pr-safe { + padding-right: env(safe-area-inset-right); +} + +/* ============================================================================= + Base Styles (touch-oriented) + ============================================================================= */ + +/* Dynamic Type: bind the rem base to the iOS "Body" text style so every rem-derived size (Tailwind's + text-sm / text-base / …, and our rem paddings) scales with the user's system text-size / accessibility + setting — the native-app expectation. `-apple-system-body` resolves to the live Dynamic Type body size + on iOS/WKWebView (≈17px at the default setting) and gracefully falls back to the browser default (16px) + elsewhere. It also pulls in the system family, which the `html, body` rule below overrides back to the + user's `--font-family`, so only the *size* is inherited from Dynamic Type. This scales text independently + of pinch-zoom (still disabled via the viewport), so accessibility text sizing works without zoom. */ +html { + font: -apple-system-body; +} + +html, +body { + height: 100%; + overflow: hidden; /* the shell owns its scroll containers; prevent whole-page bounce */ + overscroll-behavior: none; + background-color: var(--surface-0); + color: var(--text-primary); + font-family: var(--font-family); + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; /* no long-press callout menu (would hijack our drag gestures) */ + -webkit-tap-highlight-color: transparent; /* no grey flash on tap */ + touch-action: manipulation; /* no double-tap zoom (pinch is blocked via viewport + gesture guards) */ +} + +/* Kill the native long-press image drag/callout. On iOS an starts a drag-and-drop session on + long-press even under `user-select: none`; that session captures the pointer and breaks any swipe + gesture started afterward (e.g. dragging the expanded player down after long-pressing its artwork). */ +img { + -webkit-user-drag: none; + -webkit-touch-callout: none; +} + +/* Re-enable text selection for inputs and editable content */ +input, +textarea, +[contenteditable='true'] { + -webkit-user-select: text; + user-select: text; +} + +*:focus { + outline: none; +} + +/* ============================================================================= + Album-art background wash (ExpandedPlayer): a slow Ken-Burns drift on the + blurred cover so the now-playing backdrop feels alive. Disabled under + reduced-motion. The base scale keeps the blurred edges off-screen. + ============================================================================= */ + +@keyframes art-wash-drift { + 0%, + 100% { + transform: scale(1.5) translate(0, 0); + } + 50% { + transform: scale(1.7) translate(-3%, -2%); + } +} + +.art-wash { + transform: scale(1.5); + animation: art-wash-drift 22s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .art-wash { + animation: none; + } +} diff --git a/apps/mobile/static/crate-logo.svg b/apps/mobile/static/crate-logo.svg new file mode 100644 index 00000000..917b836e --- /dev/null +++ b/apps/mobile/static/crate-logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/svelte.config.js b/apps/mobile/svelte.config.js similarity index 52% rename from svelte.config.js rename to apps/mobile/svelte.config.js index 69b94f8a..19987516 100644 --- a/svelte.config.js +++ b/apps/mobile/svelte.config.js @@ -1,9 +1,9 @@ -// Tauri doesn't have a Node.js server to do proper SSR -// so we use adapter-static with a fallback to index.html to put the site in SPA mode -// See: https://svelte.dev/docs/kit/single-page-apps -// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info import adapter from '@sveltejs/adapter-static' import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const dirname = path.dirname(fileURLToPath(import.meta.url)) /** @type {import('@sveltejs/kit').Config} */ const config = { @@ -12,6 +12,9 @@ const config = { adapter: adapter({ fallback: 'index.html', }), + alias: { + $shared: path.resolve(dirname, '../../shared'), + }, }, } diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json new file mode 100644 index 00000000..bc47221e --- /dev/null +++ b/apps/mobile/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + }, + "include": [ + ".svelte-kit/ambient.d.ts", + ".svelte-kit/non-ambient.d.ts", + ".svelte-kit/types/**/$types.d.ts", + "vite.config.ts", + "src/**/*.js", + "src/**/*.ts", + "src/**/*.svelte", + "../../shared/**/*.ts", + "../../shared/**/*.svelte" + ], + "exclude": ["node_modules/**", "../../node_modules/**"] +} diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts new file mode 100644 index 00000000..e8b3209b --- /dev/null +++ b/apps/mobile/vite.config.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' +import { sveltekit } from '@sveltejs/kit/vite' +import tailwindcss from '@tailwindcss/vite' + +// `tauri ios/android dev` sets TAURI_DEV_HOST to the machine's LAN IP so a physical device can +// reach the dev server; on a simulator/emulator it's unset and Tauri forwards localhost. The port +// is fixed (1421, distinct from desktop's 1420) because Tauri's `devUrl` points at it. +const host = process.env.TAURI_DEV_HOST + +// Surface the app version (repo-root package.json is the source of truth, two levels up) as +// PUBLIC_APP_VERSION for the splash: app.html reads %sveltekit.env.PUBLIC_APP_VERSION% and the Svelte +// SplashScreen reads it via $env/static/public. Mirrors apps/desktop/vite.config.ts. +const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf-8')) +const crateEnv = process.env.CRATE_ENV +if (crateEnv === 'staging') { + const build = process.env.CRATE_BUILD_NUMBER + process.env.PUBLIC_APP_VERSION = build ? `${pkg.version}-staging.${build}` : `${pkg.version}-staging` +} else if (!crateEnv || crateEnv === 'development') { + process.env.PUBLIC_APP_VERSION = `${pkg.version}-dev` +} else { + process.env.PUBLIC_APP_VERSION = pkg.version +} + +export default defineConfig({ + plugins: [sveltekit(), tailwindcss()], + clearScreen: false, + server: { + host: host || false, + port: 1421, + strictPort: true, + hmr: host ? { protocol: 'ws', host, port: 1430 } : undefined, + watch: { ignored: ['**/src-tauri/**'] }, + }, +}) diff --git a/docs/src/content/docs/getting-started/index.md b/docs/src/content/docs/getting-started/index.md index a264a04b..b454ebc7 100644 --- a/docs/src/content/docs/getting-started/index.md +++ b/docs/src/content/docs/getting-started/index.md @@ -29,6 +29,26 @@ Download the latest version of Crate for your operating system from the [release 2. Make it executable: `chmod +x Crate-*.AppImage` 3. Run the AppImage +### iOS (TestFlight) + +1. Install [TestFlight](https://apps.apple.com/app/testflight/id899247664) from the App Store +2. Open the Crate TestFlight public link (shared by the developer) +3. Tap **Accept** and then **Install** +4. Open Crate from your Home Screen + +TestFlight builds are available worldwide (including EU and Japan). When the app is live on the App Store, you can install it directly from there instead. + +### Android + +1. Download the `Crate__android.apk` and its `.sha256` file from the [releases page](https://github.com/blackboxaudio/crate/releases) +2. Verify the download integrity: + ```bash + sha256sum -c Crate__android.apk.sha256 + ``` +3. Enable "Install from unknown sources" for your browser (Settings → Apps → Special access → Install unknown apps) +4. Open the APK to install +5. Launch Crate from your app drawer + ## First Launch When you first open Crate, you'll see an empty library. The interface consists of: diff --git a/eslint.config.js b/eslint.config.js index 071eb99e..cbcf7264 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -44,7 +44,6 @@ export default defineConfig( }, { files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], - basePath: 'src', languageOptions: { parserOptions: { projectService: true, diff --git a/package.json b/package.json index 18c54372..5d1edd09 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,37 @@ { - "name": "crate", - "version": "0.2.9", + "name": "@bbx-audio/crate", + "version": "0.3.0-staging.19", "description": "Cross-platform DJ library manager with music discovery, track analysis, and USB export", "type": "module", + "private": true, + "workspaces": [ + "apps/*" + ], "scripts": { - "dev": "cross-env CRATE_ENV=development tauri dev --config src-tauri/tauri.dev.conf.json -- --release --features devtools", - "dev:vite": "vite dev", + "dev": "cross-env CRATE_ENV=development tauri dev --config src-tauri/tauri.dev.conf.json -- --release --features desktop --features devtools", + "dev:vite": "yarn workspace @bbx-audio/crate-desktop dev:vite", "build": "yarn build:production", - "build:production": "cross-env CRATE_ENV=production tauri build --config src-tauri/tauri.prod.conf.json", - "build:staging": "cross-env CRATE_ENV=staging tauri build --config src-tauri/tauri.staging.conf.json -- --features devtools", - "build:vite": "vite build", + "build:production": "cross-env CRATE_ENV=production tauri build --config src-tauri/tauri.prod.conf.json --features desktop", + "build:staging": "cross-env CRATE_ENV=staging tauri build --config src-tauri/tauri.staging.conf.json -- --features desktop --features devtools", + "build:vite": "yarn workspace @bbx-audio/crate-desktop build:vite", + "dev:ios": "yarn ios:plist:write && node scripts/write-mobile-icons.mjs --platform ios --channel dev && node scripts/write-ios-bundle-id.mjs --channel dev && node scripts/write-ios-entitlements.mjs --channel dev && cross-env CRATE_ENV=development TAURI_DEV_HOST=$(ipconfig getifaddr en0) tauri ios dev --config src-tauri/tauri.dev.conf.json --features mobile", + "dev:android": "node scripts/write-mobile-icons.mjs --platform android --channel dev && node scripts/write-android-channel.mjs --channel dev && cross-env CRATE_ENV=development TAURI_DEV_HOST=$(ipconfig getifaddr en0) tauri android dev --features mobile", + "build:ios": "yarn ios:plist:write && node scripts/write-mobile-icons.mjs --platform ios --channel prod && node scripts/write-ios-bundle-id.mjs --channel prod && node scripts/write-ios-entitlements.mjs --channel prod && cross-env CRATE_ENV=production tauri ios build --config src-tauri/tauri.prod.conf.json --features mobile", + "build:ios:appstore": "yarn ios:plist:write && node scripts/write-mobile-icons.mjs --platform ios --channel prod && node scripts/write-ios-bundle-id.mjs --channel prod && node scripts/write-ios-entitlements.mjs --channel prod && cross-env CRATE_ENV=production tauri ios build --export-method app-store-connect --features mobile", + "build:ios:adhoc": "yarn ios:plist:write && node scripts/write-mobile-icons.mjs --platform ios --channel prod && node scripts/write-ios-bundle-id.mjs --channel prod && node scripts/write-ios-entitlements.mjs --channel prod && cross-env CRATE_ENV=production tauri ios build --export-method ad-hoc --features mobile", + "build:android": "node scripts/write-mobile-icons.mjs --platform android --channel prod && node scripts/write-android-channel.mjs --channel prod && cross-env CRATE_ENV=production tauri android build --features mobile", + "build:android:apk": "node scripts/write-mobile-icons.mjs --platform android --channel prod && node scripts/write-android-channel.mjs --channel prod && cross-env CRATE_ENV=production tauri android build --apk --features mobile", + "build:android:aab": "node scripts/write-mobile-icons.mjs --platform android --channel prod && node scripts/write-android-channel.mjs --channel prod && cross-env CRATE_ENV=production tauri android build --aab --features mobile", + "build:staging:android:apk": "node scripts/write-mobile-icons.mjs --platform android --channel staging && node scripts/write-android-channel.mjs --channel staging && cross-env CRATE_ENV=staging tauri android build --apk --features mobile", + "build:staging:ios": "yarn ios:plist:write && node scripts/write-mobile-icons.mjs --platform ios --channel staging && node scripts/write-ios-bundle-id.mjs --channel staging && node scripts/write-ios-entitlements.mjs --channel staging && cross-env CRATE_ENV=staging tauri ios build --config src-tauri/tauri.staging.conf.json --features mobile && node scripts/install-ios-device.mjs --channel staging", + "cloud:config:write": "node scripts/write-cloud-config.mjs", + "ios:plist:write": "node scripts/write-ios-plist.mjs", + "mobile:icons:write": "node scripts/write-mobile-icons.mjs", "check": "yarn check:cargo && yarn check:svelte", - "check:cargo": "cd src-tauri/ && cargo check --release", - "check:svelte": "svelte-kit sync && svelte-check --tsconfig tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig tsconfig.json --watch", + "check:cargo": "cd src-tauri/ && cargo check --release --features desktop", + "check:svelte": "yarn workspace @bbx-audio/crate-desktop check:svelte", + "check:svelte:mobile": "yarn workspace @bbx-audio/crate-mobile check:svelte", + "check:watch": "yarn workspace @bbx-audio/crate-desktop check:watch", "format": "yarn format:fix && yarn format:rust", "format:check": "prettier --check \"**/*.{ts,js,json,svelte,css}\"", "format:fix": "prettier --write \"**/*.{ts,js,json,svelte,css}\"", @@ -21,36 +39,21 @@ "lint": "yarn lint:fix && yarn lint:rust", "lint:check": "eslint --cache --debug \"**/*.{ts,js,svelte}\"", "lint:fix": "eslint --cache --fix \"**/*.{ts,js,svelte}\"", - "lint:rust": "cd src-tauri/ && cargo +nightly clippy --release", + "lint:rust": "cd src-tauri/ && cargo +nightly clippy --release --features desktop", "pre-commit": "lint-staged", "prepare": "yarn prepare:husky", "prepare:husky": "husky install", - "preview": "vite preview", + "preview": "yarn workspace @bbx-audio/crate-desktop preview", "tauri": "tauri", "bump": "node scripts/version.js", "changelog:prepare": "node scripts/changelog.js prepare", "changelog:graduate": "node scripts/changelog.js graduate" }, "license": "SEE LICENSE IN LICENSE", - "dependencies": { - "@tanstack/virtual-core": "3.13.21", - "@tauri-apps/api": "2.11.0", - "@tauri-apps/plugin-clipboard-manager": "2.3.2", - "@tauri-apps/plugin-dialog": "2.7.1", - "@tauri-apps/plugin-fs": "2.5.1", - "@tauri-apps/plugin-opener": "2.5.3", - "@tauri-apps/plugin-process": "2.3.1", - "@tauri-apps/plugin-updater": "2.10.0", - "svelte-i18n": "4.0.1" - }, "devDependencies": { "@eslint/compat": "2.0.0", "@eslint/eslintrc": "3.3.3", "@eslint/js": "9.39.2", - "@sveltejs/adapter-static": "3.0.10", - "@sveltejs/kit": "2.49.2", - "@sveltejs/vite-plugin-svelte": "6.2.1", - "@tailwindcss/vite": "4.1.18", "@tauri-apps/cli": "2", "@types/node": "22.19.3", "cross-env": "10.1.0", @@ -64,12 +67,8 @@ "prettier": "3.7.4", "prettier-plugin-svelte": "3.4.1", "prettier-plugin-tailwindcss": "0.7.2", - "svelte": "5.46.0", - "svelte-check": "4.3.4", - "tailwindcss": "4.1.18", "typescript": "5.9.3", - "typescript-eslint": "8.50.0", - "vite": "7.3.0" + "typescript-eslint": "8.50.0" }, "lint-staged": { "**/*.{ts,js,svelte,json,css}": "yarn format:fix", diff --git a/scripts/fix-ios-icons.mjs b/scripts/fix-ios-icons.mjs new file mode 100644 index 00000000..2b60d94a --- /dev/null +++ b/scripts/fix-ios-icons.mjs @@ -0,0 +1,346 @@ +#!/usr/bin/env node +/** + * Regenerate the iOS app-icon set (`src-tauri/icons/{dev,staging,prod}/ios/*.png`) as + * full-bleed, opaque squares. + * + * Why this exists: the per-channel masters (`icons/{channel}/icon.png` + `icon.icns`) are + * macOS-style icons — a rounded squircle with a transparent margin and an alpha channel. + * That shape is correct for the macOS `.icns` (the OS expects the rounded, padded artwork), + * but it is WRONG for iOS: iOS forbids alpha in app icons, flattens any transparency to + * white, and then applies its OWN squircle mask. Feeding it the rounded-with-margin master + * produces a visible white border around the artwork on the Home Screen. + * + * `tauri icon icons/{channel}/icon.png` generates every platform (desktop + ios + android) + * from that single macOS-shaped master, so it re-introduces the bug for iOS each time it runs. + * Run THIS script afterwards to correct the iOS set. + * + * What it does, per channel: + * 1. Extracts the highest-resolution representation (1024px) from `icon.icns` via `iconutil`. + * 2. Crops to the squircle's bounding box (drops the transparent margin so the artwork + * fills the frame the way an iOS icon should). + * 3. Bleeds each row's edge colour outward to fill the rounded-corner triangles, then drops + * the alpha channel entirely — yielding a full-bleed, fully-opaque RGB square. iOS then + * applies its own corner mask cleanly, with no white border. + * 4. Resizes that corrected master into every size already present in `icons/{channel}/ios/` + * and writes a 1024px `icons/{channel}/icon-ios.png` as the documented source of truth. + * + * Zero npm deps: PNG codec is hand-rolled on `node:zlib`; `iconutil` is ambient on macOS. + * + * Usage: node scripts/fix-ios-icons.mjs + */ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { deflateSync, inflateSync } from 'node:zlib' +import { fileURLToPath } from 'node:url' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const iconsRoot = resolve(repoRoot, 'src-tauri/icons') +const CHANNELS = ['dev', 'staging', 'prod'] +const ALPHA_T = 16 // alpha above this counts as "opaque artwork" + +// ---- CRC32 (for PNG chunk checksums) ---- +const CRC_TABLE = (() => { + const t = new Uint32Array(256) + for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + t[n] = c >>> 0 + } + return t +})() +function crc32(buf) { + let c = 0xffffffff + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8) + return (c ^ 0xffffffff) >>> 0 +} + +/** Decode an 8-bit, non-interlaced PNG (colour type 2 RGB or 6 RGBA). Returns {w,h,ch,data}. */ +function decodePng(buf) { + if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('not a PNG') + let off = 8 + let w, h, ct + const idat = [] + while (off < buf.length) { + const len = buf.readUInt32BE(off) + const type = buf.toString('ascii', off + 4, off + 8) + const data = buf.subarray(off + 8, off + 8 + len) + if (type === 'IHDR') { + w = data.readUInt32BE(0) + h = data.readUInt32BE(4) + const bitDepth = data[8] + ct = data[9] + const interlace = data[12] + if (bitDepth !== 8 || interlace !== 0 || (ct !== 2 && ct !== 6)) + throw new Error(`unsupported PNG (bitDepth=${bitDepth} colourType=${ct} interlace=${interlace})`) + } else if (type === 'IDAT') idat.push(Buffer.from(data)) + else if (type === 'IEND') break + off += 12 + len + } + const raw = inflateSync(Buffer.concat(idat)) + const ch = ct === 6 ? 4 : 3 + const stride = w * ch + const out = Buffer.alloc(h * stride) + let pos = 0 + const paeth = (a, b, c) => { + const p = a + b - c + const pa = Math.abs(p - a) + const pb = Math.abs(p - b) + const pc = Math.abs(p - c) + return pa <= pb && pa <= pc ? a : pb <= pc ? b : c + } + for (let y = 0; y < h; y++) { + const ft = raw[pos++] + for (let x = 0; x < stride; x++) { + const v = raw[pos++] + const a = x >= ch ? out[y * stride + x - ch] : 0 + const b = y > 0 ? out[(y - 1) * stride + x] : 0 + const c = x >= ch && y > 0 ? out[(y - 1) * stride + x - ch] : 0 + let r + if (ft === 0) r = v + else if (ft === 1) r = v + a + else if (ft === 2) r = v + b + else if (ft === 3) r = v + ((a + b) >> 1) + else if (ft === 4) r = v + paeth(a, b, c) + else throw new Error('bad PNG filter ' + ft) + out[y * stride + x] = r & 0xff + } + } + return { w, h, ch, data: out } +} + +/** Encode a {w,h,data} RGB image as an 8-bit colour-type-2 (no alpha) PNG. */ +function encodePng({ w, h, data }) { + const stride = w * 3 + const raw = Buffer.alloc((stride + 1) * h) + for (let y = 0; y < h; y++) { + raw[y * (stride + 1)] = 0 // filter: None + data.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride) + } + const comp = deflateSync(raw, { level: 9 }) + const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + const parts = [sig] + const chunk = (type, body) => { + const len = Buffer.alloc(4) + len.writeUInt32BE(body.length, 0) + const t = Buffer.from(type, 'ascii') + const crc = Buffer.alloc(4) + crc.writeUInt32BE(crc32(Buffer.concat([t, body])), 0) + parts.push(len, t, body, crc) + } + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(w, 0) + ihdr.writeUInt32BE(h, 4) + ihdr[8] = 8 // bit depth + ihdr[9] = 2 // colour type: truecolour (RGB, no alpha) + chunk('IHDR', ihdr) + chunk('IDAT', comp) + chunk('IEND', Buffer.alloc(0)) + return Buffer.concat(parts) +} + +/** + * Convert the macOS-style rounded-with-margin RGBA master into a full-bleed opaque RGB square. + * + * The squircle carries a thin (~2-3px) perimeter outline stroke whose colour contrasts with the + * fill (light on the dark/teal channels, dark on the white one). A naive "bleed the outermost + * opaque pixel outward" fills the corners with that OUTLINE colour, which reintroduces a border. + * Instead we: + * 1. Crop to the squircle's alpha bounding box. + * 2. Erode the opaque mask by a few pixels to drop the outline stroke, yielding the true + * interior (background fill + centred artwork). + * 3. Bleed that interior outward — horizontally, then vertically — so every margin / rounded- + * corner pixel takes the nearest *background* colour (preserving the subtle vertical + * gradient), never the outline. Alpha is dropped entirely. + */ +function flattenFullBleed({ w, h, ch, data }) { + const opaque = (x, y) => (ch === 4 ? data[(y * w + x) * 4 + 3] > ALPHA_T : true) + let minx = w + let miny = h + let maxx = -1 + let maxy = -1 + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) + if (opaque(x, y)) { + if (x < minx) minx = x + if (x > maxx) maxx = x + if (y < miny) miny = y + if (y > maxy) maxy = y + } + if (maxx < 0) { + minx = 0 + miny = 0 + maxx = w - 1 + maxy = h - 1 + } + const cw = maxx - minx + 1 + const chh = maxy - miny + 1 + + // Erosion radius: enough to strip the thin outline, far less than the artwork's edge padding. + const erode = Math.max(6, Math.round(0.012 * Math.max(cw, chh))) + const op = new Uint8Array(cw * chh) + for (let y = 0; y < chh; y++) for (let x = 0; x < cw; x++) op[y * cw + x] = opaque(minx + x, miny + y) ? 1 : 0 + // Separable binary erosion with a square structuring element (rows, then columns). + const rowEr = new Uint8Array(cw * chh) + for (let y = 0; y < chh; y++) + for (let x = 0; x < cw; x++) { + let keep = 1 + for (let k = -erode; k <= erode; k++) { + const xx = x + k + if (xx < 0 || xx >= cw || !op[y * cw + xx]) { + keep = 0 + break + } + } + rowEr[y * cw + x] = keep + } + const interior = new Uint8Array(cw * chh) + for (let x = 0; x < cw; x++) + for (let y = 0; y < chh; y++) { + let keep = 1 + for (let k = -erode; k <= erode; k++) { + const yy = y + k + if (yy < 0 || yy >= chh || !rowEr[yy * cw + x]) { + keep = 0 + break + } + } + interior[y * cw + x] = keep + } + + const out = Buffer.alloc(cw * chh * 3) + const filled = new Uint8Array(cw * chh) + const fromSrc = (ox, oy) => { + const s = ((miny + oy) * w + (minx + ox)) * ch + const o = (oy * cw + ox) * 3 + out[o] = data[s] + out[o + 1] = data[s + 1] + out[o + 2] = data[s + 2] + filled[oy * cw + ox] = 1 + } + const fromOut = (ox, oy, fx, fy) => { + const so = (fy * cw + fx) * 3 + const o = (oy * cw + ox) * 3 + out[o] = out[so] + out[o + 1] = out[so + 1] + out[o + 2] = out[so + 2] + filled[oy * cw + ox] = 1 + } + // Keep interior pixels verbatim. + for (let y = 0; y < chh; y++) for (let x = 0; x < cw; x++) if (interior[y * cw + x]) fromSrc(x, y) + // Horizontal bleed: extend each row's interior edge colour out to the frame. + for (let y = 0; y < chh; y++) { + let first = -1 + let last = -1 + for (let x = 0; x < cw; x++) + if (filled[y * cw + x]) { + if (first < 0) first = x + last = x + } + if (first < 0) continue + for (let x = 0; x < first; x++) fromOut(x, y, first, y) + for (let x = last + 1; x < cw; x++) fromOut(x, y, last, y) + } + // Vertical bleed: fill any rows that had no interior (top/bottom strips) from nearest filled. + for (let x = 0; x < cw; x++) { + let first = -1 + let last = -1 + for (let y = 0; y < chh; y++) + if (filled[y * cw + x]) { + if (first < 0) first = y + last = y + } + if (first < 0) continue + for (let y = 0; y < first; y++) fromOut(x, y, x, first) + for (let y = last + 1; y < chh; y++) fromOut(x, y, x, last) + } + return { w: cw, h: chh, data: out } +} + +/** Resize RGB: area-average when downscaling, bilinear when upscaling. */ +function resizeRGB({ w: sw, h: sh, data }, tw, th) { + const out = Buffer.alloc(tw * th * 3) + const upscaling = tw > sw || th > sh + if (upscaling) { + for (let ty = 0; ty < th; ty++) { + const fy = ((ty + 0.5) * sh) / th - 0.5 + let y0 = Math.floor(fy) + let wy = fy - y0 + if (y0 < 0) { + y0 = 0 + wy = 0 + } + const y1 = Math.min(y0 + 1, sh - 1) + for (let tx = 0; tx < tw; tx++) { + const fx = ((tx + 0.5) * sw) / tw - 0.5 + let x0 = Math.floor(fx) + let wx = fx - x0 + if (x0 < 0) { + x0 = 0 + wx = 0 + } + const x1 = Math.min(x0 + 1, sw - 1) + const o = (ty * tw + tx) * 3 + for (let c = 0; c < 3; c++) { + const p00 = data[(y0 * sw + x0) * 3 + c] + const p01 = data[(y0 * sw + x1) * 3 + c] + const p10 = data[(y1 * sw + x0) * 3 + c] + const p11 = data[(y1 * sw + x1) * 3 + c] + const top = p00 + (p01 - p00) * wx + const bot = p10 + (p11 - p10) * wx + out[o + c] = Math.round(top + (bot - top) * wy) + } + } + } + } else { + for (let ty = 0; ty < th; ty++) { + const sy0 = Math.floor((ty * sh) / th) + const sy1 = Math.max(sy0 + 1, Math.floor(((ty + 1) * sh) / th)) + for (let tx = 0; tx < tw; tx++) { + const sx0 = Math.floor((tx * sw) / tw) + const sx1 = Math.max(sx0 + 1, Math.floor(((tx + 1) * sw) / tw)) + let r = 0 + let g = 0 + let b = 0 + let n = 0 + for (let yy = sy0; yy < sy1; yy++) + for (let xx = sx0; xx < sx1; xx++) { + const s = (yy * sw + xx) * 3 + r += data[s] + g += data[s + 1] + b += data[s + 2] + n++ + } + const o = (ty * tw + tx) * 3 + out[o] = Math.round(r / n) + out[o + 1] = Math.round(g / n) + out[o + 2] = Math.round(b / n) + } + } + } + return { w: tw, h: th, data: out } +} + +for (const channel of CHANNELS) { + const chDir = join(iconsRoot, channel) + const iosDir = join(chDir, 'ios') + const work = mkdtempSync(join(tmpdir(), `crate-icns-${channel}-`)) + try { + const iconset = join(work, 'icon.iconset') + execFileSync('iconutil', ['-c', 'iconset', join(chDir, 'icon.icns'), '-o', iconset]) + const src = decodePng(readFileSync(join(iconset, 'icon_512x512@2x.png'))) + const corrected = flattenFullBleed(src) + writeFileSync(join(chDir, 'icon-ios.png'), encodePng(resizeRGB(corrected, 1024, 1024))) + const targets = readdirSync(iosDir).filter((n) => n.endsWith('.png')) + for (const name of targets) { + const { w } = decodePng(readFileSync(join(iosDir, name))) + writeFileSync(join(iosDir, name), encodePng(resizeRGB(corrected, w, w))) + } + console.log( + `[fix-ios-icons] ${channel}: ${targets.length} icons regenerated from ${corrected.w}px master (full-bleed, no alpha) + icon-ios.png` + ) + } finally { + rmSync(work, { recursive: true, force: true }) + } +} diff --git a/scripts/install-ios-device.mjs b/scripts/install-ios-device.mjs new file mode 100644 index 00000000..dee99b25 --- /dev/null +++ b/scripts/install-ios-device.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +/** + * Side-load a freshly-built iOS app onto a connected, trusted iPhone — no Xcode UI, no App Store + * Connect. This is the final step of `yarn build:staging:ios` (and any other `build:*:ios`) so a + * single command produces a signed `.ipa` and installs it for untethered, on-the-go testing. + * + * Why this works without TestFlight: the `build:*:ios` scripts export a development-signed IPA + * (`ExportOptions.plist` method `debugging`) under the paid developer team. On a registered device + * that build runs untethered — survives reboots, needs no cable to launch — until the provisioning + * profile expires (~1 year). The device is registered automatically the first time you run + * `yarn dev:ios` against it, so a phone you've already dev-run on needs no extra setup here. + * + * The device is resolved automatically via `xcrun devicectl` (ships with Xcode 15+); the only + * prerequisite is that the iPhone is plugged in over USB, unlocked, and has trusted this Mac. + * + * Channel resolution mirrors `write-mobile-icons.mjs`: `--channel ` wins, else + * `CRATE_ENV`, else `prod`. The channel picks the matching `tauri..conf.json`, whose + * `productName` is also the name Tauri gives the exported `.ipa` (e.g. "Crate Staging.ipa"). + * + * Usage: + * node scripts/install-ios-device.mjs [--channel dev|staging|prod] + */ +import { execFileSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const buildDir = resolve(repoRoot, 'src-tauri/gen/apple/build') + +/** Read the value following a `--flag` in argv, or null. */ +function getArg(name) { + const i = process.argv.indexOf(name) + return i !== -1 && i + 1 < process.argv.length ? process.argv[i + 1] : null +} + +function fail(message) { + console.error(`[install-ios-device] ${message}`) + process.exit(1) +} + +// Accept both the short folder names and the CRATE_ENV names, so callers can pass either. +const CHANNEL_BY_KEY = { + dev: 'dev', + development: 'dev', + staging: 'staging', + prod: 'prod', + production: 'prod', +} + +const CONFIG_BY_CHANNEL = { + dev: 'tauri.dev.conf.json', + staging: 'tauri.staging.conf.json', + prod: 'tauri.prod.conf.json', +} + +/** Resolve the channel: explicit `--channel`, else CRATE_ENV, else `prod`. */ +function resolveChannel() { + const fromArg = getArg('--channel') + if (fromArg) { + const channel = CHANNEL_BY_KEY[fromArg.toLowerCase()] + if (!channel) fail(`unknown --channel "${fromArg}" (expected dev|staging|prod)`) + return channel + } + const fromEnv = process.env.CRATE_ENV + if (fromEnv && CHANNEL_BY_KEY[fromEnv.toLowerCase()]) return CHANNEL_BY_KEY[fromEnv.toLowerCase()] + return 'prod' +} + +const channel = resolveChannel() + +// The IPA is named after the channel config's `productName`. Fall back to the newest `.ipa` in the +// build dir if the expected name isn't there (covers any future productName drift). +const configPath = resolve(repoRoot, 'src-tauri', CONFIG_BY_CHANNEL[channel]) +const productName = JSON.parse(readFileSync(configPath, 'utf8')).productName + +// Tauri exports the IPA into an arch subdirectory (e.g. `build/arm64/Crate Staging.ipa`), so search +// the whole build tree. Match the exact productName only — NEVER fall back to a differently-named +// IPA: a stale `Crate Dev.ipa` left in `build/` would otherwise get installed over the dev app, +// which is exactly how a staging run can silently clobber the wrong app. +function findFiles(dir, fileName) { + if (!existsSync(dir)) return [] + const out = [] + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + if (entry.isDirectory()) out.push(...findFiles(full, fileName)) + else if (entry.isFile() && entry.name === fileName) out.push(full) + } + return out +} + +const matches = findFiles(buildDir, `${productName}.ipa`) + .map((f) => ({ f, mtime: statSync(f).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime) +if (matches.length === 0) { + fail(`no "${productName}.ipa" found under ${buildDir} — did the \`tauri ios build\` step succeed?`) +} +const ipaPath = matches[0].f + +// Resolve a connected iOS device via devicectl's JSON output. +let devices = [] +try { + const out = join(mkdtempSync(join(tmpdir(), 'crate-ios-')), 'devices.json') + execFileSync('xcrun', ['devicectl', 'list', 'devices', '--json-output', out], { stdio: 'ignore' }) + devices = JSON.parse(readFileSync(out, 'utf8'))?.result?.devices ?? [] +} catch { + fail('could not run `xcrun devicectl` — Xcode 15+ with command line tools is required') +} + +const phones = devices.filter((d) => d?.hardwareProperties?.platform === 'iOS') +if (phones.length === 0) { + fail('no iPhone detected — plug it in over USB, unlock it, and tap "Trust This Computer", then re-run') +} + +// Prefer an actively-connected device; otherwise take the first detected iPhone and let devicectl +// establish the connection (it errors clearly if the device truly isn't reachable). +const connected = phones.filter((d) => d?.connectionProperties?.tunnelState === 'connected') +const device = connected[0] ?? phones[0] +const udid = device?.hardwareProperties?.udid +const name = device?.deviceProperties?.name ?? 'iPhone' +if (phones.length > 1) console.log(`[install-ios-device] ${phones.length} devices found; installing to "${name}"`) + +console.log(`[install-ios-device] installing "${productName}" → ${name} (${udid})`) +try { + execFileSync('xcrun', ['devicectl', 'device', 'install', 'app', '--device', udid, ipaPath], { stdio: 'inherit' }) +} catch { + fail( + `install failed — make sure "${name}" is unlocked and trusted, then retry with:\n` + + ` xcrun devicectl device install app --device ${udid} "${ipaPath}"`, + ) +} +console.log(`[install-ios-device] done — unplug and launch "${productName}" from the home screen`) diff --git a/scripts/tag.sh b/scripts/tag.sh index 9ba70341..4e55ce55 100755 --- a/scripts/tag.sh +++ b/scripts/tag.sh @@ -198,9 +198,9 @@ echo "" if $TEST_BUILD; then echo -e "${BLUE}Step 5: Testing build...${NC}" if $DRY_RUN; then - echo -e "${YELLOW}[DRY RUN] Would run: yarn tauri build${NC}" + echo -e "${YELLOW}[DRY RUN] Would run: yarn tauri build --features desktop${NC}" else - yarn tauri build + yarn tauri build --features desktop fi echo "" fi diff --git a/scripts/write-cloud-config.mjs b/scripts/write-cloud-config.mjs new file mode 100644 index 00000000..b45a5b25 --- /dev/null +++ b/scripts/write-cloud-config.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +/** + * Write `src-tauri/cloud_sync.config.json` from `GCLOUD_*` environment variables. + * + * Used by CI **mobile** release builds: they ship no config file (it's gitignored) and run through + * Xcode / Gradle, which strip shell env before `cargo` — so `build.rs` (which bakes the config in + * via `option_env!`) needs the values on disk. This step materializes them from GitHub repository + * secrets so `build.rs` can read them, exactly like local dev reads the developer's own file. + * + * Desktop CI builds do NOT need this — plain `cargo`/`tauri build` lets `option_env!` read the + * ambient `GCLOUD_*` directly. Mobile compile-check jobs (`cargo check`) don't need it either. + * + * Safe by design: + * - Refuses to overwrite an existing file (protects a developer's local config); pass `--force`. + * - Exits 0 (not 1) when the 5 core secrets aren't all present, so PRs from forks — which don't + * receive secrets — still build (just without cloud sync) instead of failing the job. + * + * Usage: node scripts/write-cloud-config.mjs [--force] + */ +import { existsSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const outPath = resolve(repoRoot, 'src-tauri/cloud_sync.config.json') +const force = process.argv.includes('--force') + +// config.json field -> env var. The 5 core fields gate `is_complete()` (config.rs); the 2 mobile +// client ids are optional but required for mobile *sign-in* to work. +const CORE = { + project_id: 'GCLOUD_PROJECT_ID', + web_api_key: 'GCLOUD_WEB_API_KEY', + storage_bucket: 'GCLOUD_STORAGE_BUCKET', + oauth_client_id: 'GCLOUD_OAUTH_CLIENT_ID', + oauth_client_secret: 'GCLOUD_OAUTH_CLIENT_SECRET', +} +const MOBILE = { + ios_oauth_client_id: 'GCLOUD_IOS_OAUTH_CLIENT_ID', + android_oauth_client_id: 'GCLOUD_ANDROID_OAUTH_CLIENT_ID', +} +// Firebase App IDs for mobile App Check (#139), optional like MOBILE. The dev-only +// `appcheck_debug_token` is deliberately NOT materialized here — it bypasses attestation and +// must never reach a release/CI build; release uses native App Attest / Play Integrity instead. +const APPCHECK = { + firebase_ios_app_id: 'GCLOUD_FIREBASE_IOS_APP_ID', + firebase_android_app_id: 'GCLOUD_FIREBASE_ANDROID_APP_ID', +} + +if (existsSync(outPath) && !force) { + console.log(`[write-cloud-config] ${outPath} already exists; leaving it untouched (pass --force to overwrite)`) + process.exit(0) +} + +const config = {} +const missing = [] +for (const [field, envVar] of Object.entries(CORE)) { + const value = process.env[envVar] + if (value && value.trim() !== '') config[field] = value + else missing.push(envVar) +} + +if (missing.length > 0) { + console.warn( + `[write-cloud-config] missing required secret(s): ${missing.join(', ')} — not writing config; ` + + 'cloud sync will be unavailable in this build', + ) + process.exit(0) +} + +for (const [field, envVar] of Object.entries(MOBILE)) { + const value = process.env[envVar] + if (value && value.trim() !== '') config[field] = value +} + +for (const [field, envVar] of Object.entries(APPCHECK)) { + const value = process.env[envVar] + if (value && value.trim() !== '') config[field] = value +} + +writeFileSync(outPath, `${JSON.stringify(config, null, 2)}\n`) +console.log(`[write-cloud-config] wrote ${outPath} with field(s): ${Object.keys(config).join(', ')}`) +if (!config.ios_oauth_client_id) { + console.warn('[write-cloud-config] GCLOUD_IOS_OAUTH_CLIENT_ID unset — iOS sign-in would fail with "mobile OAuth client id not configured"') +} +if (!config.android_oauth_client_id) { + console.warn('[write-cloud-config] GCLOUD_ANDROID_OAUTH_CLIENT_ID unset — Android sign-in would fail with "mobile OAuth client id not configured"') +} diff --git a/scripts/write-ios-bundle-id.mjs b/scripts/write-ios-bundle-id.mjs new file mode 100644 index 00000000..cde24c68 --- /dev/null +++ b/scripts/write-ios-bundle-id.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * Set the iOS app's bundle identifier (and product name) in `src-tauri/gen/apple/project.yml` for + * the given release channel, so each channel ships a distinct bundle that matches its App Store + * Connect app / provisioning profile / Firebase app: + * dev -> com.bbx-audio.crate.dev ("Crate Dev") — local `yarn dev:ios` only + * staging -> com.bbx-audio.crate.staging ("Crate Staging") — TestFlight + * prod -> com.bbx-audio.crate ("Crate") — App Store + * + * Why a script: the committed `project.yml` is the XcodeGen source the iOS build consumes, and it + * is NOT channel-aware on its own. CI builds iOS without passing a Tauri `--config`, so the bundle + * id comes purely from this file — the per-channel Tauri configs (`tauri..conf.json`) carry + * the matching identifiers but don't reach the iOS build. This keeps iOS in sync with them. Mirrors + * `write-mobile-icons.mjs` / `write-ios-plist.mjs`, run before `tauri ios dev|build`. + * + * The committed default stays `dev`, so a bare build / fresh clone is the dev bundle; staging/prod + * builds run this first. Idempotent — it rewrites whatever `com.bbx-audio.crate*` value is present. + * + * Usage: node scripts/write-ios-bundle-id.mjs --channel + */ +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const projectYmlPath = resolve(repoRoot, 'src-tauri/gen/apple/project.yml') + +const CHANNELS = { + dev: { bundleId: 'com.bbx-audio.crate.dev', productName: 'Crate Dev' }, + staging: { bundleId: 'com.bbx-audio.crate.staging', productName: 'Crate Staging' }, + prod: { bundleId: 'com.bbx-audio.crate', productName: 'Crate' }, +} + +const channelArgIndex = process.argv.indexOf('--channel') +const channel = channelArgIndex !== -1 ? process.argv[channelArgIndex + 1] : undefined +const target = channel && CHANNELS[channel] +if (!target) { + console.error( + `[write-ios-bundle-id] missing or invalid --channel (expected one of: ${Object.keys(CHANNELS).join(', ')})`, + ) + process.exit(1) +} + +let yml +try { + yml = readFileSync(projectYmlPath, 'utf8') +} catch (err) { + console.error(`[write-ios-bundle-id] could not read ${projectYmlPath}: ${err.message}`) + process.exit(1) +} + +// Replace each key's value, preserving the existing indentation. The bundle-id lines match any +// current `com.bbx-audio.crate*` value so the script is idempotent across repeated channel runs. +const replacements = [ + [/^([ \t]*bundleIdPrefix:[ \t]*).*$/m, `$1${target.bundleId}`], + [/^([ \t]*PRODUCT_BUNDLE_IDENTIFIER:[ \t]*).*$/m, `$1${target.bundleId}`], + [/^([ \t]*PRODUCT_NAME:[ \t]*).*$/m, `$1${target.productName}`], +] + +for (const [pattern, replacement] of replacements) { + if (!pattern.test(yml)) { + console.error(`[write-ios-bundle-id] expected key not found in project.yml: ${pattern}`) + process.exit(1) + } + yml = yml.replace(pattern, replacement) +} + +writeFileSync(projectYmlPath, yml) +console.log( + `[write-ios-bundle-id] set iOS bundle id to ${target.bundleId} ("${target.productName}") for channel "${channel}"`, +) diff --git a/scripts/write-ios-entitlements.mjs b/scripts/write-ios-entitlements.mjs new file mode 100644 index 00000000..2308056b --- /dev/null +++ b/scripts/write-ios-entitlements.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +/** + * Stamp the iOS app's entitlements for the given release channel, controlling the **App Attest** + * (Firebase App Check, #139) entitlement per channel: + * dev -> no App Attest entitlement (uses an App Check *debug token* instead; works on device + * and simulator with no attestation/provisioning setup, so `yarn dev:ios` signing is + * unchanged) + * staging -> com.apple.developer.devicecheck.appattest-environment = production (TestFlight) + * prod -> com.apple.developer.devicecheck.appattest-environment = production (App Store) + * + * TestFlight and App Store builds BOTH use the `production` App Attest environment (only a build + * run/installed directly from Xcode uses `development`), so staging and prod are identical here. + * + * Why a script: `src-tauri/gen/apple/crate-app_iOS/crate-app_iOS.entitlements` is the committed + * XcodeGen entitlements file the iOS build consumes, and it is NOT channel-aware on its own. CI + * builds iOS without passing a Tauri `--config`, so the entitlements come purely from this file. + * Mirrors `write-ios-bundle-id.mjs` / `write-ios-plist.mjs`, run before `tauri ios dev|build`. + * + * The committed default stays `dev` (no App Attest entitlement), so a bare build / fresh clone + * signs unchanged; staging/prod builds run this first. Idempotent — it rewrites the whole `` + * body regardless of the file's prior channel, preserving the explanatory comment above ``. + * + * NOTE: `production` App Attest requires the channel's App ID to permit App Attest and a matching + * provisioning profile (usually added automatically by App Store Connect signing). Validate on + * staging/TestFlight before prod. + * + * Usage: node scripts/write-ios-entitlements.mjs --channel + */ +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const entitlementsPath = resolve(repoRoot, 'src-tauri/gen/apple/crate-app_iOS/crate-app_iOS.entitlements') + +// Per-channel App Attest environment (null = omit the entitlement entirely). +const CHANNELS = { + dev: { appAttestEnv: null }, + staging: { appAttestEnv: 'production' }, + prod: { appAttestEnv: 'production' }, +} + +const channelArgIndex = process.argv.indexOf('--channel') +const channel = channelArgIndex !== -1 ? process.argv[channelArgIndex + 1] : undefined +const target = channel && CHANNELS[channel] +if (!target) { + console.error( + `[write-ios-entitlements] missing or invalid --channel (expected one of: ${Object.keys(CHANNELS).join(', ')})`, + ) + process.exit(1) +} + +// The explanatory comment documenting the App Attest entitlement, kept above the so the file +// stays self-describing whichever channel it's currently stamped for. +const comment = `` + +const dictBody = + target.appAttestEnv === null + ? '' + : ` + com.apple.developer.devicecheck.appattest-environment + ${target.appAttestEnv} +` + +const entitlements = ` + +${comment} + +${dictBody} + +` + +// Sanity-check the target exists (fail loud if the generated project layout moved). +try { + readFileSync(entitlementsPath, 'utf8') +} catch (err) { + console.error(`[write-ios-entitlements] could not read ${entitlementsPath}: ${err.message}`) + process.exit(1) +} + +writeFileSync(entitlementsPath, entitlements) +console.log( + `[write-ios-entitlements] channel "${channel}": App Attest environment ${ + target.appAttestEnv === null ? 'omitted (debug-token dev)' : `"${target.appAttestEnv}"` + }`, +) diff --git a/scripts/write-ios-plist.mjs b/scripts/write-ios-plist.mjs new file mode 100644 index 00000000..b9f9787b --- /dev/null +++ b/scripts/write-ios-plist.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +/** + * Generate `src-tauri/Info.ios.plist`, which Tauri v2 auto-merges into the iOS app's `Info.plist` + * at `tauri ios dev|build` time (additive for new keys; no config wiring needed). + * + * It adds a couple of things the generated project doesn't have: + * - `UIBackgroundModes: [audio, fetch]` — `audio` lets discovery preview playback continue in the + * background (#79); `fetch` enables opportunistic background cloud sync via `BGAppRefreshTask` (#61). + * - `BGTaskSchedulerPermittedIdentifiers: [audio.bbx.crate.sync.refresh]` — the background-sync task + * identifier must be declared here or `BGTaskScheduler` registration/submission throws at runtime. + * MUST match `TASK_IDENTIFIER` in `src-tauri/src/services/cloud_sync/background/ios.rs`. + * + * It deliberately does NOT register the Google OAuth callback URL scheme (the reversed client id). + * Native cloud sign-in (#133) uses `ASWebAuthenticationSession` (via `tauri-plugin-web-auth`), which + * intercepts its `callbackURLScheme` internally — it does not need, and actively conflicts with, a + * `CFBundleURLTypes` registration for the same scheme. When the scheme is claimed in `Info.plist`, + * iOS routes the OAuth redirect to the app's URL-open path instead of the in-flight auth session, so + * the session's completion handler never fires and sign-in hangs on "Signing in…" forever. Leaving + * the scheme unregistered lets the auth session own the callback. (Android is different: Custom Tabs + * *do* need the scheme in `AndroidManifest.xml`, which is why it stays registered there.) + * + * `src-tauri/gen/apple` is gitignored (regenerated by `cargo tauri ios init`), so editing the + * generated plist wouldn't persist — this merge file is the supported mechanism. + * + * The output is itself gitignored (kept out of git alongside `cloud_sync.config.json`). It is fully + * derived, so it's overwritten on every run. + * + * Usage: node scripts/write-ios-plist.mjs + */ +import { writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const outPath = resolve(repoRoot, 'src-tauri/Info.ios.plist') + +const plist = ` + + + + UIBackgroundModes + + audio + fetch + + BGTaskSchedulerPermittedIdentifiers + + audio.bbx.crate.sync.refresh + + + +` + +writeFileSync(outPath, plist) + +console.log( + `[write-ios-plist] wrote ${outPath} (UIBackgroundModes: audio, fetch; BGTaskSchedulerPermittedIdentifiers: audio.bbx.crate.sync.refresh)` +) diff --git a/scripts/write-ios-signing.mjs b/scripts/write-ios-signing.mjs new file mode 100644 index 00000000..d83975d1 --- /dev/null +++ b/scripts/write-ios-signing.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * Configure manual distribution code-signing in the iOS Xcode project for CI release builds. + * + * Tauri uses the committed `project.pbxproj` as-is at `tauri ios build` — it does NOT regenerate it + * from `project.yml` (a `project.yml` change to the signing settings had no effect). Tauri applies + * the bundle id and switches on manual signing MODE, but it never selects a provisioning profile, + * so a non-interactive CI archive fails with: "requires a provisioning profile. Select a + * provisioning profile in the Signing & Capabilities editor." This rewrites the iOS target's + * signing in the pbxproj to manual + Apple Distribution + the channel's named profile. + * + * CI-ONLY: intentionally NOT wired into the local `dev:ios` / `build:*:ios` scripts, so the + * committed pbxproj keeps automatic/development signing and local on-device `yarn dev:ios` is + * unaffected. The edit happens on the CI runner's checkout and is never committed. + * + * Usage: IOS_PROVISIONING_PROFILE_NAME="" node scripts/write-ios-signing.mjs + */ +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const pbxprojPath = resolve(repoRoot, 'src-tauri/gen/apple/crate-app.xcodeproj/project.pbxproj') + +const SIGN_IDENTITY = 'Apple Distribution' + +const argIdx = process.argv.indexOf('--profile-name') +const profileName = ( + argIdx !== -1 ? process.argv[argIdx + 1] : process.env.IOS_PROVISIONING_PROFILE_NAME || '' +).trim() +if (profileName === '') { + console.error( + '[write-ios-signing] no provisioning profile name — set IOS_PROVISIONING_PROFILE_NAME (or pass ' + + '--profile-name). Cannot configure manual signing.', + ) + process.exit(1) +} + +let pbx +try { + pbx = readFileSync(pbxprojPath, 'utf8') +} catch (err) { + console.error(`[write-ios-signing] could not read ${pbxprojPath}: ${err.message}`) + process.exit(1) +} + +// The committed project signs automatically with a development identity (`iPhone Developer`). +// Replace that line in each target build config (debug + release) with manual Apple Distribution +// signing + the named profile, reusing the captured indentation. CI archives the release config; +// debug gets the same settings, harmlessly. +const pattern = /^([\t ]*)CODE_SIGN_IDENTITY = "iPhone Developer";$/gm +const count = (pbx.match(pattern) || []).length +if (count === 0) { + console.error( + '[write-ios-signing] `CODE_SIGN_IDENTITY = "iPhone Developer";` not found in pbxproj — the ' + + 'project structure changed; update this script.', + ) + process.exit(1) +} +pbx = pbx.replace( + pattern, + (_match, indent) => + `${indent}CODE_SIGN_IDENTITY = "${SIGN_IDENTITY}";\n` + + `${indent}CODE_SIGN_STYLE = Manual;\n` + + `${indent}PROVISIONING_PROFILE_SPECIFIER = "${profileName}";`, +) + +writeFileSync(pbxprojPath, pbx) +console.log( + `[write-ios-signing] configured manual signing (${SIGN_IDENTITY}, profile "${profileName}") in ${count} build config(s)`, +) diff --git a/scripts/write-mobile-icons.mjs b/scripts/write-mobile-icons.mjs new file mode 100644 index 00000000..a1b49458 --- /dev/null +++ b/scripts/write-mobile-icons.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/** + * Materialize the channel-specific mobile app icons into the generated native projects + * (`src-tauri/gen/apple` and `src-tauri/gen/android`) before a `tauri ios|android dev|build`. + * + * Why a prebuild step instead of config? Desktop varies its icon per channel through each config's + * `bundle.icon` array (`icons/{dev,staging,prod}/...`), but Tauri mobile has no equivalent — the iOS + * asset catalog and the Android `res/mipmap-*` resources are read straight from the gen projects at + * build time. So the only way to ship a dev/staging/prod icon on mobile is to copy the right set into + * the gen project first. The branded per-channel sets already live in `src-tauri/icons/{channel}/{ios, + * android}/` (generated by `tauri icon`); this script just selects one and stamps it into gen. + * + * The icon files it writes under gen are gitignored (see `src-tauri/.gitignore`) — `icons/{channel}/` + * is the source of truth and these are derived artifacts, exactly like `Info.ios.plist` and + * `cloud_sync.config.json`. That keeps `yarn dev:ios`/`dev:android` from churning the working tree. + * + * Channel resolution: `--channel ` wins; else `CRATE_ENV` + * (development|staging|production) is mapped; else defaults to `prod` (the release identity). + * + * Usage: + * node scripts/write-mobile-icons.mjs [--platform ios|android|all] [--channel dev|staging|prod] + */ +import { cpSync, existsSync, readdirSync, statSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const iconsRoot = resolve(repoRoot, 'src-tauri/icons') +const iosDest = resolve(repoRoot, 'src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset') +const androidResDest = resolve(repoRoot, 'src-tauri/gen/android/app/src/main/res') + +/** Read the value following a `--flag` in argv, or null. */ +function getArg(name) { + const i = process.argv.indexOf(name) + return i !== -1 && i + 1 < process.argv.length ? process.argv[i + 1] : null +} + +// Accept both the short folder names and the CRATE_ENV names, so callers can pass either. +const CHANNEL_BY_KEY = { + dev: 'dev', + development: 'dev', + staging: 'staging', + prod: 'prod', + production: 'prod', +} + +/** Resolve the channel: explicit `--channel`, else CRATE_ENV, else `prod`. */ +function resolveChannel() { + const fromArg = getArg('--channel') + if (fromArg) { + const channel = CHANNEL_BY_KEY[fromArg.toLowerCase()] + if (!channel) { + console.error(`[write-mobile-icons] unknown --channel "${fromArg}" (expected dev|staging|prod)`) + process.exit(1) + } + return channel + } + const fromEnv = process.env.CRATE_ENV + if (fromEnv && CHANNEL_BY_KEY[fromEnv.toLowerCase()]) return CHANNEL_BY_KEY[fromEnv.toLowerCase()] + return 'prod' +} + +/** Count files in a directory tree, for logging. */ +function countFiles(dir) { + let n = 0 + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + if (entry.isDirectory()) n += countFiles(full) + else if (entry.isFile()) n += 1 + } + return n +} + +const platform = (getArg('--platform') || 'all').toLowerCase() +if (!['ios', 'android', 'all'].includes(platform)) { + console.error(`[write-mobile-icons] unknown --platform "${platform}" (expected ios|android|all)`) + process.exit(1) +} +const channel = resolveChannel() + +/** + * Merge-copy a channel's icon set into a gen destination. `cpSync({recursive})` mirrors the source + * tree into the destination (creating dirs like Android's `mipmap-anydpi-v26/`) and overwrites + * matching files, while leaving sibling files untouched — so iOS keeps its `Contents.json` and + * Android keeps `colors.xml`/`strings.xml`/`themes.xml`. + */ +function stamp(label, src, dest) { + if (!existsSync(src) || !statSync(src).isDirectory()) { + console.error(`[write-mobile-icons] missing source icons: ${src}`) + process.exit(1) + } + cpSync(src, dest, { recursive: true, force: true }) + console.log(`[write-mobile-icons] ${label}: copied ${countFiles(src)} file(s) from ${channel} → ${dest}`) +} + +if (platform === 'ios' || platform === 'all') { + stamp('iOS', resolve(iconsRoot, channel, 'ios'), iosDest) +} +if (platform === 'android' || platform === 'all') { + stamp('Android', resolve(iconsRoot, channel, 'android'), androidResDest) +} diff --git a/src/lib/api/analysis.ts b/shared/api/analysis.ts similarity index 95% rename from src/lib/api/analysis.ts rename to shared/api/analysis.ts index 277fed83..723c3978 100644 --- a/src/lib/api/analysis.ts +++ b/shared/api/analysis.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core' -import type { Track } from '$lib/types' +import type { Track } from '../types' /** * Analyze tracks for BPM and key detection diff --git a/src/lib/api/app.ts b/shared/api/app.ts similarity index 100% rename from src/lib/api/app.ts rename to shared/api/app.ts diff --git a/src/lib/api/backup.ts b/shared/api/backup.ts similarity index 100% rename from src/lib/api/backup.ts rename to shared/api/backup.ts diff --git a/src/lib/api/cloudSync.ts b/shared/api/cloudSync.ts similarity index 59% rename from src/lib/api/cloudSync.ts rename to shared/api/cloudSync.ts index 8629830a..8b223198 100644 --- a/src/lib/api/cloudSync.ts +++ b/shared/api/cloudSync.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core' -import type { CloudSyncStatus, CloudDeviceRecord, LibraryRoot } from '$lib/types' +import type { CloudSyncStatus, CloudDeviceRecord, LibraryRoot } from '../types' // Auth + sync @@ -7,6 +7,18 @@ export async function signIn(providerId: string): Promise { return invoke('sign_in', { providerId }) } +// Native mobile sign-in (two-step): `begin_sign_in` returns the consent URL + callback scheme +// for the platform auth session (ASWebAuthenticationSession / Custom Tabs); the frontend then +// hands the resulting `code`/`state` back via `complete_sign_in`. + +export async function beginSignIn(providerId: string): Promise<{ authUrl: string; callbackScheme: string }> { + return invoke<{ authUrl: string; callbackScheme: string }>('begin_sign_in', { providerId }) +} + +export async function completeSignIn(code: string, oauthState: string): Promise { + return invoke('complete_sign_in', { code, oauthState }) +} + export async function signOut(): Promise { return invoke('sign_out') } @@ -23,6 +35,25 @@ export async function pullNow(): Promise { return invoke('pull_now') } +/** One-shot foreground sync (mobile): pull, then push if there are local edits. No-op when signed out. */ +export async function syncForeground(): Promise { + return invoke('sync_foreground') +} + +/** + * Arm opportunistic background sync (mobile: iOS BGTaskScheduler / Android WorkManager). Call after + * sign-in and on launch when already signed in. The command is mobile-only; on desktop the invoke + * rejects (unknown command), so callers swallow the error. + */ +export async function scheduleBackgroundSync(): Promise { + return invoke('schedule_background_sync') +} + +/** Cancel any scheduled background sync (mobile; called on sign-out). Mobile-only command. */ +export async function cancelBackgroundSync(): Promise { + return invoke('cancel_background_sync') +} + // Devices export async function listDevices(): Promise { diff --git a/src/lib/api/devices.ts b/shared/api/devices.ts similarity index 93% rename from src/lib/api/devices.ts rename to shared/api/devices.ts index 47dd931d..187df567 100644 --- a/src/lib/api/devices.ts +++ b/shared/api/devices.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core' -import type { UsbDevice } from '$lib/types' +import type { UsbDevice } from '../types' /** * Get all connected removable USB devices diff --git a/src/lib/api/diagnostics.ts b/shared/api/diagnostics.ts similarity index 98% rename from src/lib/api/diagnostics.ts rename to shared/api/diagnostics.ts index 1be84103..7b8e12ff 100644 --- a/src/lib/api/diagnostics.ts +++ b/shared/api/diagnostics.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core' -import type { DiagnosticEntry, DiagnosticsReport, SystemInfo } from '$lib/types' +import type { DiagnosticEntry, DiagnosticsReport, SystemInfo } from '../types' /** * Get all diagnostic entries diff --git a/src/lib/api/discovery.ts b/shared/api/discovery.ts similarity index 80% rename from src/lib/api/discovery.ts rename to shared/api/discovery.ts index 1df1a87c..d8413b49 100644 --- a/src/lib/api/discovery.ts +++ b/shared/api/discovery.ts @@ -10,7 +10,7 @@ import type { ScannedPage, ScannedRelease, BulkImportResult, -} from '$lib/types' +} from '../types' export async function createRelease(create: DiscoveryReleaseCreate): Promise { return invoke('create_discovery_release', { create }) @@ -87,6 +87,22 @@ export async function invalidatePreviewStreamCache(releaseId: string): Promise('invalidate_preview_stream_cache', { releaseId }) } +/** Per-release audio-cache state for the "downloaded for offline" indicator. */ +export interface ReleaseCacheState { + cached_tracks: number + total_tracks: number + bytes: number +} + +export async function getReleaseCacheState(releaseId: string): Promise { + return invoke('get_release_cache_state', { releaseId }) +} + +/** Proactively download + cache one track's audio for offline playback. Idempotent. */ +export async function precachePreviewStream(releaseId: string, trackPosition: number): Promise { + return invoke('precache_preview_stream', { releaseId, trackPosition }) +} + export async function getAudioCacheSize(): Promise { return invoke('get_discovery_audio_cache_size') } @@ -95,6 +111,23 @@ export async function clearAudioCache(): Promise { return invoke('clear_discovery_audio_cache') } +/** + * Download + cache a release's remote cover to disk (idempotent). Returns the relative cache + * path ("discovery/artwork/{id}.ext") for cache-first offline rendering, or null when the + * release has no artwork_url or the fetch fails. + */ +export async function cacheReleaseArtwork(releaseId: string): Promise { + return invoke('cache_release_artwork', { releaseId }) +} + +export async function getArtworkCacheSize(): Promise { + return invoke('get_discovery_artwork_cache_size') +} + +export async function clearArtworkCache(): Promise { + return invoke('clear_discovery_artwork_cache') +} + export async function setDiscoveryReleaseArtwork(id: string, filePath: string): Promise { return invoke('set_discovery_release_artwork', { releaseId: id, filePath }) } diff --git a/src/lib/api/export.ts b/shared/api/export.ts similarity index 97% rename from src/lib/api/export.ts rename to shared/api/export.ts index f987e60f..2ceae0e1 100644 --- a/src/lib/api/export.ts +++ b/shared/api/export.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core' -import type { ExportRequest, ExportResult, DeviceExport, ExportCheckpoint } from '$lib/types' +import type { ExportRequest, ExportResult, DeviceExport, ExportCheckpoint } from '../types' /** * Export playlists to a USB device diff --git a/src/lib/api/follow.ts b/shared/api/follow.ts similarity index 97% rename from src/lib/api/follow.ts rename to shared/api/follow.ts index 64297dbe..43944dd1 100644 --- a/src/lib/api/follow.ts +++ b/shared/api/follow.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core' -import type { FollowedReleasesFound, FollowedSource, FollowedSourceCreate, SourceCheckResult } from '$lib/types' +import type { FollowedReleasesFound, FollowedSource, FollowedSourceCreate, SourceCheckResult } from '../types' /** Follow a pasted artist/label page URL (backend scans to detect type + baseline). */ export async function followSource(url: string): Promise { diff --git a/src/lib/api/index.ts b/shared/api/index.ts similarity index 100% rename from src/lib/api/index.ts rename to shared/api/index.ts diff --git a/src/lib/api/library.ts b/shared/api/library.ts similarity index 99% rename from src/lib/api/library.ts rename to shared/api/library.ts index fb5c1989..995d66cc 100644 --- a/src/lib/api/library.ts +++ b/shared/api/library.ts @@ -8,7 +8,7 @@ import type { TrackColor, TrackFilter, TrackUpdate, -} from '$lib/types' +} from '../types' /** * Import tracks from file paths into the library diff --git a/src/lib/api/mediaControls.ts b/shared/api/mediaControls.ts similarity index 100% rename from src/lib/api/mediaControls.ts rename to shared/api/mediaControls.ts diff --git a/src/lib/api/menu.ts b/shared/api/menu.ts similarity index 100% rename from src/lib/api/menu.ts rename to shared/api/menu.ts diff --git a/src/lib/api/player.ts b/shared/api/player.ts similarity index 96% rename from src/lib/api/player.ts rename to shared/api/player.ts index 888aab96..db40adef 100644 --- a/src/lib/api/player.ts +++ b/shared/api/player.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core' -import type { PlaybackState } from '$lib/types' +import type { PlaybackState } from '../types' /** * Start playing a track by ID diff --git a/src/lib/api/playlists.ts b/shared/api/playlists.ts similarity index 88% rename from src/lib/api/playlists.ts rename to shared/api/playlists.ts index d7359e0f..d2552004 100644 --- a/src/lib/api/playlists.ts +++ b/shared/api/playlists.ts @@ -4,9 +4,10 @@ import type { MoveConflictResolution, MovePlaylistResult, Playlist, + PlaylistCoverArt, SmartRules, Track, -} from '$lib/types' +} from '../types' /** * Get all playlists for a given context @@ -111,6 +112,18 @@ export async function getPlaylistReleases(playlistId: string): Promise('get_playlist_releases', { playlistId }) } +export async function reorderPlaylistReleases(playlistId: string, releaseIds: string[]): Promise { + return invoke('reorder_playlist_releases', { playlistId, releaseIds }) +} + +/** + * Get up to 4 distinct release covers per playlist, for mosaic thumbnails. Lightweight + * (no tracks/tags) so it can be batched across every playlist visible in a list. + */ +export async function getPlaylistCoverArt(playlistIds: string[]): Promise { + return invoke('get_playlist_cover_art', { playlistIds }) +} + /** * Create a new smart playlist */ diff --git a/src/lib/api/settings.ts b/shared/api/settings.ts similarity index 92% rename from src/lib/api/settings.ts rename to shared/api/settings.ts index 5a2d99bb..7d970e49 100644 --- a/src/lib/api/settings.ts +++ b/shared/api/settings.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core' -import type { AppSettings, AudioDevice } from '$lib/types' +import type { AppSettings, AudioDevice } from '../types' /** * Get all application settings diff --git a/src/lib/api/sync.ts b/shared/api/sync.ts similarity index 97% rename from src/lib/api/sync.ts rename to shared/api/sync.ts index 22fa8c3b..a104e1c1 100644 --- a/src/lib/api/sync.ts +++ b/shared/api/sync.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core' -import type { SyncResult, DeviceInfo } from '$lib/types' +import type { SyncResult, DeviceInfo } from '../types' /** * Sync playlists to a USB device (incremental sync) diff --git a/src/lib/api/tags.ts b/shared/api/tags.ts similarity index 97% rename from src/lib/api/tags.ts rename to shared/api/tags.ts index 2fec2297..b7960838 100644 --- a/src/lib/api/tags.ts +++ b/shared/api/tags.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core' -import type { Tag, TagCategory } from '$lib/types' +import type { Tag, TagCategory } from '../types' /** * Get all tag categories with their tags diff --git a/src/lib/api/updater.ts b/shared/api/updater.ts similarity index 100% rename from src/lib/api/updater.ts rename to shared/api/updater.ts diff --git a/shared/components/Slider.svelte b/shared/components/Slider.svelte new file mode 100644 index 00000000..43a57399 --- /dev/null +++ b/shared/components/Slider.svelte @@ -0,0 +1,230 @@ + + + +
+ +
+ + diff --git a/src/lib/i18n/index.ts b/shared/i18n/index.ts similarity index 100% rename from src/lib/i18n/index.ts rename to shared/i18n/index.ts diff --git a/src/lib/i18n/locales/de.json b/shared/i18n/locales/de.json similarity index 89% rename from src/lib/i18n/locales/de.json rename to shared/i18n/locales/de.json index 50754aa2..d3db2a5d 100644 --- a/src/lib/i18n/locales/de.json +++ b/shared/i18n/locales/de.json @@ -1,5 +1,6 @@ { "common": { + "add": "Hinzufügen", "close": "Schließen", "save": "Speichern", "search": "Suchen...", @@ -20,13 +21,18 @@ "folder": "Ordner", "playlist": "Playlist", "next": "Weiter", - "back": "Zurück" + "back": "Zurück", + "more": "Mehr", + "select": "Auswählen", + "noResults": "Keine Ergebnisse" }, "nav": { "library": "Bibliothek", "discovery": "Entdeckungen", "playlists": "Playlists", - "tags": "Tags" + "tags": "Tags", + "openMenu": "Menü öffnen", + "openSettings": "Einstellungen öffnen" }, "settings": { "title": "Einstellungen", @@ -117,7 +123,12 @@ "cacheSize": "Cache-Größe", "clearCache": "Cache löschen", "clearCacheConfirmMessage": "Möchten Sie alle zwischengespeicherten Audio-Vorschaudateien wirklich löschen?", - "clearCacheWarning": "Zuvor angehörte Tracks müssen beim nächsten Abspielen erneut von ihrer Quelle gestreamt werden. Dies kann langsamer sein und einige Streams sind möglicherweise nicht mehr verfügbar." + "clearCacheWarning": "Zuvor angehörte Tracks müssen beim nächsten Abspielen erneut von ihrer Quelle gestreamt werden. Dies kann langsamer sein und einige Streams sind möglicherweise nicht mehr verfügbar.", + "audioCache": "Audio", + "artworkCache": "Artwork", + "artworkCacheDescription": "Album-Cover werden auf diesem Gerät gespeichert, damit sie auch ohne Verbindung angezeigt werden.", + "clearArtworkCacheConfirmMessage": "Möchten Sie alle zwischengespeicherten Album-Cover wirklich löschen?", + "cacheLimit": "Cache-Limit" }, "following": { "title": "Folgt", @@ -179,7 +190,8 @@ "updates": "Updates", "checkForUpdates": "Auf Updates prüfen", "upToDate": "Aktuell", - "updateAvailable": "{version} verfügbar" + "updateAvailable": "{version} verfügbar", + "project": "Projekt" } }, "cloudSync": { @@ -200,6 +212,9 @@ "syncNow": "Jetzt synchronisieren", "syncing": "Wird synchronisiert…", "signOut": "Abmelden", + "signOutConfirmTitle": "Abmelden?", + "signOutConfirmMessage": "Ihre lokalen Daten bleiben auf diesem Gerät. Die Synchronisierung wird gestoppt, bis Sie sich erneut anmelden.", + "autoSyncHint": "Ihre Discovery-Bibliothek wird automatisch auf allen Ihren Geräten synchronisiert.", "lastSynced": "Zuletzt synchronisiert {time}" }, "devices": { @@ -245,7 +260,26 @@ "volume": "Lautstärke", "tempo": "Tempo", "resetTempo": "Tempo zurücksetzen", - "trackNotFound": "Der aktuell gespielte Titel konnte nicht gefunden werden" + "trackNotFound": "Der aktuell gespielte Titel konnte nicht gefunden werden", + "expand": "Player ausklappen", + "collapse": "Player einklappen", + "seek": "Suchen" + }, + "queue": { + "upNext": "Als Nächstes", + "openQueue": "Übernächstes", + "playNext": "Als nächstes abspielen", + "addToQueue": "Zur Warteschlange hinzufügen", + "playingNext": "Wird als nächstes abgespielt", + "addedToQueue": "Zur Warteschlange hinzugefügt", + "nextInQueue": "Übernächstes in der Warteschlange", + "upNextFromContext": "Als Nächstes", + "addedByYou": "Von dir hinzugefügt", + "removeFromQueue": "Aus der Warteschlange entfernen", + "reorder": "Neu anordnen", + "clearQueue": "Löschen", + "empty": "Nichts übernächstes", + "emptyHint": "Die Lieder, die du hinzufügst, werden als nächstes abgespielt" }, "library": { "searchPlaceholder": "Tracks suchen...", @@ -277,7 +311,18 @@ "releases": "Releases", "searchPlaceholder": "Releases suchen...", "noReleasesYet": "Noch keine Releases", + "noResults": "Keine Releases entsprechen Ihren Filtern", "addReleaseHint": "Drücken Sie {shortcut}, um Releases von Bandcamp, Discogs, YouTube und mehr hinzuzufügen", + "mobileAddHint": "Releases von Bandcamp, Discogs, YouTube und mehr hinzufügen", + "pending": "Ausstehend", + "offlineQueueNotice": "Sie sind offline – dieses Release wird hinzugefügt, wenn Sie die Verbindung wiederherstellen", + "addToQueue": "Zur Warteschlange hinzufügen", + "editRelease": "Release bearbeiten", + "goToRelease": "Zu Release gehen", + "sortBy": "Sortieren nach", + "selectedCount": "{count, plural, =1 {1 ausgewählt} other {{count} ausgewählt}}", + "confirmDeleteTitle": "{count, plural, =1 {Release löschen?} other {{count} Releases löschen?}}", + "confirmDeleteMessage": "Dies kann nicht rückgängig gemacht werden.", "previewNotAvailable": "Vorschau für diese Quelle nicht verfügbar", "columns": { "artistTitle": "Künstler / Titel", @@ -289,11 +334,17 @@ }, "fetchingMetadata": "Metadaten werden abgerufen...", "refreshMetadata": "Metadaten aktualisieren", + "downloadForOffline": "Für Offline herunterladen", + "downloading": "Wird heruntergeladen...", + "downloadedForOffline": "Heruntergeladen", + "removeDownload": "Download entfernen", + "downloadFailed": "Offline-Download fehlgeschlagen", "fetchError": "Metadaten von dieser URL konnten nicht abgerufen werden", "unsupportedUrl": "Diese URL wird nicht unterstützt", "tracks": "Tracks", "trackCount": "{count, plural, =1 {1 Track} other {{count} Tracks}}", "openInBrowser": "Im Browser öffnen", + "openInApp": "In {app} öffnen", "copyUrl": "URL kopieren", "copiedUrl": "URL in Zwischenablage kopiert", "like": "Markieren", @@ -312,7 +363,9 @@ "title": "Titel", "label": "Label", "releaseDate": "Release-Datum", - "notes": "Notizen" + "notes": "Notizen", + "notesPlaceholder": "Notizen hinzufügen…", + "addTags": "Tags hinzufügen" }, "import": { "title": "In Bibliothek importieren", @@ -423,6 +476,11 @@ "rootNoFolder": "Stammverzeichnis (kein Ordner)", "folderEmpty": "Dieser Ordner ist leer", "noPlaylistsYet": "Noch keine Playlists", + "noPlaylists": "Noch keine Playlists", + "allPlaylists": "Alle Playlists", + "searchPlaceholder": "Playlists durchsuchen…", + "emptyHint": "Erstellen Sie Playlists, um Ihre Entdeckungen zu organisieren", + "detailEmptyHint": "Halten Sie lange ein Release in Entdeckungen gedrückt, um es zu einer Playlist hinzuzufügen", "exportToDevice": "Auf Gerät exportieren" }, "tags": { @@ -436,7 +494,8 @@ "addTag": "Tag hinzufügen", "moveToCategory": "In Kategorie verschieben", "noTags": "Keine Tags", - "noTagCategoriesYet": "Noch keine Tag-Kategorien" + "noTagCategoriesYet": "Noch keine Tag-Kategorien", + "emptyHint": "Erstellen Sie farbcodierte Tag-Kategorien, um Ihre Entdeckungen zu organisieren" }, "contextMenu": { "viewInFinder": "Im Finder anzeigen", @@ -701,6 +760,9 @@ "emerald": "Smaragd" }, "dates": { + "justNow": "Gerade eben", + "minutesAgo": "{count, plural, =1 {vor 1 Minute} other {vor {count} Minuten}}", + "hoursAgo": "{count, plural, =1 {vor 1 Stunde} other {vor {count} Stunden}}", "today": "Heute", "yesterday": "Gestern", "daysAgo": "{count, plural, =1 {vor 1 Tag} other {vor {count} Tagen}}", @@ -891,6 +953,26 @@ "title": "Sie sind startklar!", "subtitle": "Beginnen Sie mit dem Aufbau Ihrer Sammlung." }, - "getStarted": "Loslegen" + "getStarted": "Loslegen", + "mobile": { + "welcome": { + "title": "Willkommen bei {appName}", + "subtitle": "Entdecke Musik und baue deine Sammlung auf – direkt aus deiner Tasche." + }, + "discover": { + "title": "Veröffentlichungen hinzufügen & Vorschau", + "description": "Fügen Sie einen Link von Bandcamp, SoundCloud und mehr ein, um eine Veröffentlichung zu speichern, und tippen Sie dann, um eine sofortige Vorschau zu erhalten." + }, + "sync": { + "title": "Mit deinem Desktop synchronisieren", + "description": "Melde dich optional an, um deine Sammlung auf all deinen Geräten zu synchronisieren. Das kannst du später immer noch tun." + }, + "appearance": { + "title": "Mach es zu deinem", + "description": "Wähle ein Design und eine Akzentfarbe. Du kannst diese jederzeit in Einstellungen ändern." + }, + "skip": "Überspringen", + "maybeLater": "Vielleicht später" + } } } diff --git a/src/lib/i18n/locales/en.json b/shared/i18n/locales/en.json similarity index 90% rename from src/lib/i18n/locales/en.json rename to shared/i18n/locales/en.json index 776aced5..63adebf7 100644 --- a/src/lib/i18n/locales/en.json +++ b/shared/i18n/locales/en.json @@ -1,5 +1,6 @@ { "common": { + "add": "Add", "close": "Close", "save": "Save", "search": "Search...", @@ -20,13 +21,18 @@ "folder": "folder", "playlist": "playlist", "next": "Next", - "back": "Back" + "back": "Back", + "more": "More", + "select": "Select", + "noResults": "No results" }, "nav": { "library": "Library", "discovery": "Discovery", "playlists": "Playlists", - "tags": "Tags" + "tags": "Tags", + "openMenu": "Open menu", + "openSettings": "Open settings" }, "settings": { "title": "Settings", @@ -117,7 +123,12 @@ "cacheSize": "Cache size", "clearCache": "Clear Cache", "clearCacheConfirmMessage": "Are you sure you want to clear all cached preview audio?", - "clearCacheWarning": "Previously previewed tracks will need to be re-streamed from their source on next play. This may be slower and some streams may no longer be available." + "clearCacheWarning": "Previously previewed tracks will need to be re-streamed from their source on next play. This may be slower and some streams may no longer be available.", + "audioCache": "Audio", + "artworkCache": "Artwork", + "artworkCacheDescription": "Album art is saved on this device so it shows without a connection.", + "clearArtworkCacheConfirmMessage": "Are you sure you want to clear all cached album art?", + "cacheLimit": "Cache limit" }, "following": { "title": "Following", @@ -179,7 +190,8 @@ "updates": "Updates", "checkForUpdates": "Check for Updates", "upToDate": "Up to date", - "updateAvailable": "{version} available" + "updateAvailable": "{version} available", + "project": "Project" } }, "cloudSync": { @@ -200,6 +212,9 @@ "syncNow": "Sync now", "syncing": "Syncing…", "signOut": "Sign out", + "signOutConfirmTitle": "Sign out?", + "signOutConfirmMessage": "Your local data stays on this device. Syncing will stop until you sign in again.", + "autoSyncHint": "Your discovery library syncs automatically across your devices.", "lastSynced": "Last synced {time}" }, "devices": { @@ -245,7 +260,26 @@ "volume": "Volume", "tempo": "Tempo", "resetTempo": "Reset tempo", - "trackNotFound": "Could not locate the playing track" + "trackNotFound": "Could not locate the playing track", + "expand": "Expand player", + "collapse": "Collapse player", + "seek": "Seek" + }, + "queue": { + "upNext": "Up Next", + "openQueue": "Up next", + "playNext": "Play next", + "addToQueue": "Add to queue", + "playingNext": "Playing next", + "addedToQueue": "Added to queue", + "nextInQueue": "Next in queue", + "upNextFromContext": "Up next", + "addedByYou": "Added by you", + "removeFromQueue": "Remove from queue", + "reorder": "Reorder", + "clearQueue": "Clear", + "empty": "Nothing up next", + "emptyHint": "Songs you add will play next" }, "library": { "searchPlaceholder": "Search tracks...", @@ -277,7 +311,18 @@ "releases": "releases", "searchPlaceholder": "Search releases...", "noReleasesYet": "No releases yet", + "noResults": "No releases match your filters", "addReleaseHint": "Press {shortcut} to add releases from Bandcamp, Discogs, YouTube, and more", + "mobileAddHint": "Add releases from Bandcamp, Discogs, YouTube, and more", + "pending": "Pending", + "offlineQueueNotice": "You're offline — this release will be added when you reconnect", + "addToQueue": "Add to Queue", + "editRelease": "Edit Release", + "goToRelease": "Go to Release", + "sortBy": "Sort by", + "selectedCount": "{count, plural, =1 {1 selected} other {{count} selected}}", + "confirmDeleteTitle": "{count, plural, =1 {Delete release?} other {Delete {count} releases?}}", + "confirmDeleteMessage": "This can't be undone.", "previewNotAvailable": "Preview not available for this source", "columns": { "artistTitle": "Artist / Title", @@ -289,11 +334,17 @@ }, "fetchingMetadata": "Fetching metadata...", "refreshMetadata": "Refresh Metadata", + "downloadForOffline": "Download for Offline", + "downloading": "Downloading...", + "downloadedForOffline": "Downloaded", + "removeDownload": "Remove Download", + "downloadFailed": "Could not download for offline", "fetchError": "Could not fetch metadata from this URL", "unsupportedUrl": "This URL is not supported", "tracks": "Tracks", "trackCount": "{count, plural, =1 {1 track} other {{count} tracks}}", "openInBrowser": "Open in Browser", + "openInApp": "Open in {app}", "copyUrl": "Copy URL", "copiedUrl": "URL copied to clipboard", "like": "Like", @@ -312,7 +363,9 @@ "title": "Title", "label": "Label", "releaseDate": "Release Date", - "notes": "Notes" + "notes": "Notes", + "notesPlaceholder": "Add notes…", + "addTags": "Add tags" }, "import": { "title": "Import to Library", @@ -423,6 +476,11 @@ "rootNoFolder": "Root (No Folder)", "folderEmpty": "This folder is empty", "noPlaylistsYet": "No playlists yet", + "noPlaylists": "No playlists yet", + "allPlaylists": "All Playlists", + "searchPlaceholder": "Search playlists…", + "emptyHint": "Create playlists to organize your discoveries", + "detailEmptyHint": "Long-press a release in Discovery to add it to a playlist", "exportToDevice": "Export to Device" }, "tags": { @@ -436,7 +494,8 @@ "addTag": "Add Tag", "moveToCategory": "Move to Category", "noTags": "No tags", - "noTagCategoriesYet": "No tag categories yet" + "noTagCategoriesYet": "No tag categories yet", + "emptyHint": "Create color-coded tag categories to organize your discoveries" }, "contextMenu": { "viewInFinder": "View in Finder", @@ -702,6 +761,9 @@ "emerald": "Emerald" }, "dates": { + "justNow": "Just now", + "minutesAgo": "{count, plural, =1 {1 minute ago} other {{count} minutes ago}}", + "hoursAgo": "{count, plural, =1 {1 hour ago} other {{count} hours ago}}", "today": "Today", "yesterday": "Yesterday", "daysAgo": "{count, plural, =1 {1 day ago} other {{count} days ago}}", @@ -892,6 +954,26 @@ "title": "You're all set!", "subtitle": "Start building your collection." }, - "getStarted": "Get Started" + "getStarted": "Get Started", + "mobile": { + "welcome": { + "title": "Welcome to {appName}", + "subtitle": "Discover music and build your collection — right from your pocket." + }, + "discover": { + "title": "Add & preview releases", + "description": "Paste a link from Bandcamp, SoundCloud, and more to save a release, then tap to preview it instantly." + }, + "sync": { + "title": "Sync with your desktop", + "description": "Optionally sign in to keep your collection in sync across your devices. You can always do this later." + }, + "appearance": { + "title": "Make it yours", + "description": "Pick a theme and accent color. You can change these anytime in Settings." + }, + "skip": "Skip", + "maybeLater": "Maybe later" + } } } diff --git a/src/lib/i18n/locales/es.json b/shared/i18n/locales/es.json similarity index 90% rename from src/lib/i18n/locales/es.json rename to shared/i18n/locales/es.json index d1e905f1..3a7274fb 100644 --- a/src/lib/i18n/locales/es.json +++ b/shared/i18n/locales/es.json @@ -1,5 +1,6 @@ { "common": { + "add": "Añadir", "close": "Cerrar", "save": "Guardar", "search": "Buscar...", @@ -20,13 +21,18 @@ "folder": "carpeta", "playlist": "lista de reproducción", "next": "Siguiente", - "back": "Atrás" + "back": "Atrás", + "more": "Más", + "select": "Seleccionar", + "noResults": "Sin resultados" }, "nav": { "library": "Biblioteca", "discovery": "Descubrimientos", "playlists": "Listas de reproducción", - "tags": "Etiquetas" + "tags": "Etiquetas", + "openMenu": "Abrir menú", + "openSettings": "Abrir configuración" }, "settings": { "title": "Configuración", @@ -117,7 +123,12 @@ "cacheSize": "Tamaño de la caché", "clearCache": "Borrar caché", "clearCacheConfirmMessage": "¿Estás seguro de que deseas borrar todo el audio de vista previa en caché?", - "clearCacheWarning": "Las pistas previsualizadas anteriormente necesitarán retransmitirse desde su fuente en la siguiente reproducción. Esto puede ser más lento y algunos streams pueden no estar disponibles." + "clearCacheWarning": "Las pistas previsualizadas anteriormente necesitarán retransmitirse desde su fuente en la siguiente reproducción. Esto puede ser más lento y algunos streams pueden no estar disponibles.", + "audioCache": "Audio", + "artworkCache": "Portada", + "artworkCacheDescription": "Las portadas de álbumes se guardan en este dispositivo para que se muestren sin conexión.", + "clearArtworkCacheConfirmMessage": "¿Estás seguro de que deseas borrar todas las portadas en caché?", + "cacheLimit": "Límite de caché" }, "following": { "title": "Siguiendo", @@ -179,7 +190,8 @@ "updates": "Actualizaciones", "checkForUpdates": "Buscar actualizaciones", "upToDate": "Al día", - "updateAvailable": "{version} disponible" + "updateAvailable": "{version} disponible", + "project": "Proyecto" } }, "cloudSync": { @@ -200,6 +212,9 @@ "syncNow": "Sincronizar ahora", "syncing": "Sincronizando…", "signOut": "Cerrar sesión", + "signOutConfirmTitle": "¿Cerrar sesión?", + "signOutConfirmMessage": "Tus datos locales permanecen en este dispositivo. La sincronización se detendrá hasta que inicies sesión de nuevo.", + "autoSyncHint": "Tu biblioteca de Discovery se sincroniza automáticamente en todos tus dispositivos.", "lastSynced": "Última sincronización {time}" }, "devices": { @@ -245,7 +260,26 @@ "volume": "Volumen", "tempo": "Tempo", "resetTempo": "Restablecer tempo", - "trackNotFound": "No se pudo localizar la pista en reproducción" + "trackNotFound": "No se pudo localizar la pista en reproducción", + "expand": "Expandir reproductor", + "collapse": "Contraer reproductor", + "seek": "Buscar" + }, + "queue": { + "upNext": "A continuación", + "openQueue": "A continuación", + "playNext": "Reproducir siguiente", + "addToQueue": "Añadir a la cola", + "playingNext": "Reproduciéndose a continuación", + "addedToQueue": "Añadido a la cola", + "nextInQueue": "Siguiente en la cola", + "upNextFromContext": "A continuación", + "addedByYou": "Añadido por ti", + "removeFromQueue": "Eliminar de la cola", + "reorder": "Reordenar", + "clearQueue": "Borrar", + "empty": "Nada a continuación", + "emptyHint": "Las canciones que añadas se reproducirán a continuación" }, "library": { "searchPlaceholder": "Buscar pistas...", @@ -277,7 +311,18 @@ "releases": "lanzamientos", "searchPlaceholder": "Buscar lanzamientos...", "noReleasesYet": "Sin lanzamientos aún", + "noResults": "Ningún lanzamiento coincide con tus filtros", "addReleaseHint": "Presiona {shortcut} para añadir lanzamientos de Bandcamp, Discogs, YouTube y más", + "mobileAddHint": "Añadir lanzamientos de Bandcamp, Discogs, YouTube y más", + "pending": "Pendiente", + "offlineQueueNotice": "Estás sin conexión — este lanzamiento se añadirá cuando te reconectes", + "addToQueue": "Añadir a la cola", + "editRelease": "Editar lanzamiento", + "goToRelease": "Ir a lanzamiento", + "sortBy": "Ordenar por", + "selectedCount": "{count, plural, =1 {1 seleccionado} other {{count} seleccionados}}", + "confirmDeleteTitle": "{count, plural, =1 {¿Eliminar lanzamiento?} other {¿Eliminar {count} lanzamientos?}}", + "confirmDeleteMessage": "Esto no se puede deshacer.", "previewNotAvailable": "Vista previa no disponible para esta fuente", "columns": { "artistTitle": "Artista / Título", @@ -289,11 +334,17 @@ }, "fetchingMetadata": "Obteniendo metadatos...", "refreshMetadata": "Actualizar metadatos", + "downloadForOffline": "Descargar para offline", + "downloading": "Descargando...", + "downloadedForOffline": "Descargado", + "removeDownload": "Eliminar descarga", + "downloadFailed": "Error al descargar para offline", "fetchError": "No se pudieron obtener los metadatos de esta URL", "unsupportedUrl": "Esta URL no es compatible", "tracks": "Pistas", "trackCount": "{count, plural, =1 {1 pista} other {{count} pistas}}", "openInBrowser": "Abrir en navegador", + "openInApp": "Abrir en {app}", "copyUrl": "Copiar URL", "copiedUrl": "URL copiada al portapapeles", "like": "Me gusta", @@ -312,7 +363,9 @@ "title": "Título", "label": "Sello", "releaseDate": "Fecha de lanzamiento", - "notes": "Notas" + "notes": "Notas", + "notesPlaceholder": "Añadir notas…", + "addTags": "Añadir etiquetas" }, "import": { "title": "Importar a la biblioteca", @@ -423,6 +476,11 @@ "rootNoFolder": "Raíz (sin carpeta)", "folderEmpty": "Esta carpeta está vacía", "noPlaylistsYet": "Sin listas de reproducción aún", + "noPlaylists": "Sin listas de reproducción aún", + "allPlaylists": "Todas las listas de reproducción", + "searchPlaceholder": "Buscar listas de reproducción…", + "emptyHint": "Crea listas de reproducción para organizar tus descubrimientos", + "detailEmptyHint": "Mantén presionado un lanzamiento en Descubrimientos para añadirlo a una lista de reproducción", "exportToDevice": "Exportar a dispositivo" }, "tags": { @@ -436,7 +494,8 @@ "addTag": "Añadir etiqueta", "moveToCategory": "Mover a categoría", "noTags": "Sin etiquetas", - "noTagCategoriesYet": "Sin categorías de etiquetas aún" + "noTagCategoriesYet": "Sin categorías de etiquetas aún", + "emptyHint": "Crea categorías de etiquetas codificadas por colores para organizar tus descubrimientos" }, "contextMenu": { "viewInFinder": "Ver en Finder", @@ -701,6 +760,9 @@ "emerald": "Esmeralda" }, "dates": { + "justNow": "Hace poco", + "minutesAgo": "{count, plural, =1 {hace 1 minuto} other {hace {count} minutos}}", + "hoursAgo": "{count, plural, =1 {hace 1 hora} other {hace {count} horas}}", "today": "Hoy", "yesterday": "Ayer", "daysAgo": "{count, plural, =1 {hace 1 día} other {hace {count} días}}", @@ -891,6 +953,26 @@ "title": "¡Ya estás listo!", "subtitle": "Comienza a construir tu colección." }, - "getStarted": "Comenzar" + "getStarted": "Comenzar", + "mobile": { + "welcome": { + "title": "Bienvenido a {appName}", + "subtitle": "Descubre música y construye tu colección — directamente desde tu bolsillo." + }, + "discover": { + "title": "Añadir y previsualizar lanzamientos", + "description": "Pega un enlace de Bandcamp, SoundCloud y más para guardar un lanzamiento, luego toca para obtener una vista previa al instante." + }, + "sync": { + "title": "Sincroniza con tu escritorio", + "description": "Opcionalmente inicia sesión para mantener tu colección sincronizada en todos tus dispositivos. Siempre puedes hacerlo más tarde." + }, + "appearance": { + "title": "Hazlo tuyo", + "description": "Elige un tema y un color de acento. Puedes cambiarlos en cualquier momento en Configuración." + }, + "skip": "Omitir", + "maybeLater": "Quizás más tarde" + } } } diff --git a/src/lib/i18n/locales/fr.json b/shared/i18n/locales/fr.json similarity index 90% rename from src/lib/i18n/locales/fr.json rename to shared/i18n/locales/fr.json index a6e0a505..f2a66f0b 100644 --- a/src/lib/i18n/locales/fr.json +++ b/shared/i18n/locales/fr.json @@ -1,5 +1,6 @@ { "common": { + "add": "Ajouter", "close": "Fermer", "save": "Enregistrer", "search": "Rechercher...", @@ -20,13 +21,18 @@ "folder": "dossier", "playlist": "playlist", "next": "Suivant", - "back": "Retour" + "back": "Retour", + "more": "Plus", + "select": "Sélectionner", + "noResults": "Aucun résultat" }, "nav": { "library": "Bibliothèque", "discovery": "Découvertes", "playlists": "Playlists", - "tags": "Tags" + "tags": "Tags", + "openMenu": "Ouvrir le menu", + "openSettings": "Ouvrir les paramètres" }, "settings": { "title": "Paramètres", @@ -117,7 +123,12 @@ "cacheSize": "Taille du cache", "clearCache": "Effacer le cache", "clearCacheConfirmMessage": "Êtes-vous sûr de vouloir effacer tous les fichiers audio d'aperçu en cache?", - "clearCacheWarning": "Les pistes précédemment prévisualisées devront être à nouveau diffusées en continu à partir de leur source lors de la lecture suivante. Cela peut être plus lent et certains flux peuvent ne plus être disponibles." + "clearCacheWarning": "Les pistes précédemment prévisualisées devront être à nouveau diffusées en continu à partir de leur source lors de la lecture suivante. Cela peut être plus lent et certains flux peuvent ne plus être disponibles.", + "audioCache": "Audio", + "artworkCache": "Pochette", + "artworkCacheDescription": "Les pochettes d'album sont enregistrées sur cet appareil pour s'afficher sans connexion.", + "clearArtworkCacheConfirmMessage": "Êtes-vous sûr de vouloir effacer toutes les pochettes en cache?", + "cacheLimit": "Limite du cache" }, "following": { "title": "Suivi", @@ -179,7 +190,8 @@ "updates": "Mises à jour", "checkForUpdates": "Vérifier les mises à jour", "upToDate": "À jour", - "updateAvailable": "{version} disponible" + "updateAvailable": "{version} disponible", + "project": "Projet" } }, "cloudSync": { @@ -200,6 +212,9 @@ "syncNow": "Synchroniser maintenant", "syncing": "Synchronisation…", "signOut": "Déconnexion", + "signOutConfirmTitle": "Se déconnecter?", + "signOutConfirmMessage": "Vos données locales restent sur cet appareil. La synchronisation s'arrêtera jusqu'à ce que vous vous reconnectiez.", + "autoSyncHint": "Votre bibliothèque Discovery se synchronise automatiquement sur tous vos appareils.", "lastSynced": "Dernière synchronisation {time}" }, "devices": { @@ -245,7 +260,26 @@ "volume": "Volume", "tempo": "Tempo", "resetTempo": "Réinitialiser le tempo", - "trackNotFound": "Impossible de localiser la piste en cours de lecture" + "trackNotFound": "Impossible de localiser la piste en cours de lecture", + "expand": "Développer le lecteur", + "collapse": "Réduire le lecteur", + "seek": "Avancer" + }, + "queue": { + "upNext": "À suivre", + "openQueue": "À suivre", + "playNext": "Lire suivant", + "addToQueue": "Ajouter à la file", + "playingNext": "En cours de lecture suivant", + "addedToQueue": "Ajouté à la file", + "nextInQueue": "Suivant dans la file", + "upNextFromContext": "À suivre", + "addedByYou": "Ajouté par vous", + "removeFromQueue": "Retirer de la file", + "reorder": "Réorganiser", + "clearQueue": "Effacer", + "empty": "Rien à suivre", + "emptyHint": "Les chansons que vous ajoutez seront lues ensuite" }, "library": { "searchPlaceholder": "Rechercher des pistes...", @@ -277,7 +311,18 @@ "releases": "sorties", "searchPlaceholder": "Rechercher des sorties...", "noReleasesYet": "Aucune sortie pour le moment", + "noResults": "Aucune sortie ne correspond à vos filtres", "addReleaseHint": "Appuyez sur {shortcut} pour ajouter des sorties de Bandcamp, Discogs, YouTube et plus", + "mobileAddHint": "Ajouter des sorties de Bandcamp, Discogs, YouTube et plus", + "pending": "En attente", + "offlineQueueNotice": "Vous êtes hors ligne — cette sortie sera ajoutée quand vous vous reconnecterez", + "addToQueue": "Ajouter à la file d'attente", + "editRelease": "Modifier la sortie", + "goToRelease": "Aller à la sortie", + "sortBy": "Trier par", + "selectedCount": "{count, plural, =1 {1 sélectionné} other {{count} sélectionnés}}", + "confirmDeleteTitle": "{count, plural, =1 {Supprimer la sortie ?} other {Supprimer {count} sorties ?}}", + "confirmDeleteMessage": "Cela ne peut pas être annulé.", "previewNotAvailable": "Aperçu non disponible pour cette source", "columns": { "artistTitle": "Artiste / Titre", @@ -289,11 +334,17 @@ }, "fetchingMetadata": "Récupération des métadonnées...", "refreshMetadata": "Actualiser les métadonnées", + "downloadForOffline": "Télécharger hors ligne", + "downloading": "Téléchargement en cours...", + "downloadedForOffline": "Téléchargé", + "removeDownload": "Supprimer le téléchargement", + "downloadFailed": "Impossible de télécharger hors ligne", "fetchError": "Impossible de récupérer les métadonnées à partir de cette URL", "unsupportedUrl": "Cette URL n'est pas supportée", "tracks": "Pistes", "trackCount": "{count, plural, =1 {1 piste} other {{count} pistes}}", "openInBrowser": "Ouvrir dans le navigateur", + "openInApp": "Ouvrir dans {app}", "copyUrl": "Copier l'URL", "copiedUrl": "URL copiée dans le presse-papiers", "like": "Ajouter aux favoris", @@ -312,7 +363,9 @@ "title": "Titre", "label": "Label", "releaseDate": "Date de sortie", - "notes": "Notes" + "notes": "Notes", + "notesPlaceholder": "Ajouter des notes…", + "addTags": "Ajouter des étiquettes" }, "import": { "title": "Importer dans la bibliothèque", @@ -423,6 +476,11 @@ "rootNoFolder": "Racine (aucun dossier)", "folderEmpty": "Ce dossier est vide", "noPlaylistsYet": "Aucune playlist pour le moment", + "noPlaylists": "Aucune playlist pour le moment", + "allPlaylists": "Toutes les playlists", + "searchPlaceholder": "Rechercher des playlists…", + "emptyHint": "Créez des playlists pour organiser vos découvertes", + "detailEmptyHint": "Appuyez longtemps sur une sortie dans Découvertes pour l'ajouter à une playlist", "exportToDevice": "Exporter vers l'appareil" }, "tags": { @@ -436,7 +494,8 @@ "addTag": "Ajouter un tag", "moveToCategory": "Déplacer vers la catégorie", "noTags": "Aucun tag", - "noTagCategoriesYet": "Aucune catégorie de tag pour le moment" + "noTagCategoriesYet": "Aucune catégorie de tag pour le moment", + "emptyHint": "Créez des catégories de tags colorées pour organiser vos découvertes" }, "contextMenu": { "viewInFinder": "Afficher dans le Finder", @@ -701,6 +760,9 @@ "emerald": "Émeraude" }, "dates": { + "justNow": "À l'instant", + "minutesAgo": "{count, plural, =1 {il y a 1 minute} other {il y a {count} minutes}}", + "hoursAgo": "{count, plural, =1 {il y a 1 heure} other {il y a {count} heures}}", "today": "Aujourd'hui", "yesterday": "Hier", "daysAgo": "{count, plural, =1 {il y a 1 jour} other {il y a {count} jours}}", @@ -891,6 +953,26 @@ "title": "Vous êtes prêt!", "subtitle": "Commencez à construire votre collection." }, - "getStarted": "Commencer" + "getStarted": "Commencer", + "mobile": { + "welcome": { + "title": "Bienvenue dans {appName}", + "subtitle": "Découvrez de la musique et construisez votre collection — directement depuis votre poche." + }, + "discover": { + "title": "Ajouter et prévisualiser les sorties", + "description": "Collez un lien de Bandcamp, SoundCloud, et plus pour enregistrer une sortie, puis appuyez pour obtenir un aperçu instantané." + }, + "sync": { + "title": "Synchronisez avec votre bureau", + "description": "Connectez-vous en option pour garder votre collection en synchronisation sur tous vos appareils. Vous pouvez toujours le faire plus tard." + }, + "appearance": { + "title": "Personnalisez-le", + "description": "Choisissez un thème et une couleur d'accent. Vous pouvez les modifier à tout moment dans Paramètres." + }, + "skip": "Ignorer", + "maybeLater": "Peut-être plus tard" + } } } diff --git a/src/lib/i18n/locales/it.json b/shared/i18n/locales/it.json similarity index 90% rename from src/lib/i18n/locales/it.json rename to shared/i18n/locales/it.json index 72848db6..7fa68333 100644 --- a/src/lib/i18n/locales/it.json +++ b/shared/i18n/locales/it.json @@ -1,5 +1,6 @@ { "common": { + "add": "Aggiungi", "close": "Chiudi", "save": "Salva", "search": "Cerca...", @@ -20,13 +21,18 @@ "folder": "cartella", "playlist": "playlist", "next": "Successivo", - "back": "Indietro" + "back": "Indietro", + "more": "Altro", + "select": "Seleziona", + "noResults": "Nessun risultato" }, "nav": { "library": "Libreria", "discovery": "Scoperte", "playlists": "Playlist", - "tags": "Tag" + "tags": "Tag", + "openMenu": "Apri menu", + "openSettings": "Apri impostazioni" }, "settings": { "title": "Impostazioni", @@ -117,7 +123,12 @@ "cacheSize": "Dimensione cache", "clearCache": "Cancella cache", "clearCacheConfirmMessage": "Sei sicuro di voler cancellare tutto l'audio di anteprima memorizzato nella cache?", - "clearCacheWarning": "I brani precedentemente visualizzati in anteprima dovranno essere trasmessi nuovamente dalla loro fonte alla prossima riproduzione. Questo potrebbe essere più lento e alcuni flussi potrebbero non essere più disponibili." + "clearCacheWarning": "I brani precedentemente visualizzati in anteprima dovranno essere trasmessi nuovamente dalla loro fonte alla prossima riproduzione. Questo potrebbe essere più lento e alcuni flussi potrebbero non essere più disponibili.", + "audioCache": "Audio", + "artworkCache": "Copertina", + "artworkCacheDescription": "Le copertine degli album vengono salvate su questo dispositivo in modo che vengano visualizzate senza connessione.", + "clearArtworkCacheConfirmMessage": "Sei sicuro di voler cancellare tutte le copertine memorizzate nella cache?", + "cacheLimit": "Limite cache" }, "following": { "title": "Seguiti", @@ -179,7 +190,8 @@ "updates": "Aggiornamenti", "checkForUpdates": "Controlla aggiornamenti", "upToDate": "Aggiornato", - "updateAvailable": "{version} disponibile" + "updateAvailable": "{version} disponibile", + "project": "Progetto" } }, "cloudSync": { @@ -200,6 +212,9 @@ "syncNow": "Sincronizza ora", "syncing": "Sincronizzazione in corso…", "signOut": "Esci", + "signOutConfirmTitle": "Uscire?", + "signOutConfirmMessage": "I tuoi dati locali rimangono su questo dispositivo. La sincronizzazione si fermerà finché non accedi di nuovo.", + "autoSyncHint": "La tua libreria Discovery si sincronizza automaticamente su tutti i tuoi dispositivi.", "lastSynced": "Ultima sincronizzazione {time}" }, "devices": { @@ -245,7 +260,26 @@ "volume": "Volume", "tempo": "Tempo", "resetTempo": "Ripristina tempo", - "trackNotFound": "Impossibile individuare la traccia in riproduzione" + "trackNotFound": "Impossibile individuare la traccia in riproduzione", + "expand": "Espandi lettore", + "collapse": "Comprimi lettore", + "seek": "Cerca" + }, + "queue": { + "upNext": "Prossimo", + "openQueue": "Prossimo", + "playNext": "Riproduci prossimo", + "addToQueue": "Aggiungi alla coda", + "playingNext": "Riproduzione prossimo", + "addedToQueue": "Aggiunto alla coda", + "nextInQueue": "Prossimo nella coda", + "upNextFromContext": "Prossimo", + "addedByYou": "Aggiunto da te", + "removeFromQueue": "Rimuovi dalla coda", + "reorder": "Riordina", + "clearQueue": "Cancella", + "empty": "Niente di prossimo", + "emptyHint": "Le canzoni che aggiungi verranno riprodotte dopo" }, "library": { "searchPlaceholder": "Cerca tracce...", @@ -277,7 +311,18 @@ "releases": "rilasci", "searchPlaceholder": "Cerca uscite...", "noReleasesYet": "Nessun rilascio per ora", + "noResults": "Nessun rilascio corrisponde ai tuoi filtri", "addReleaseHint": "Premi {shortcut} per aggiungere rilasci da Bandcamp, Discogs, YouTube e altri", + "mobileAddHint": "Aggiungi rilasci da Bandcamp, Discogs, YouTube e altri", + "pending": "In sospeso", + "offlineQueueNotice": "Sei offline — questo rilascio verrà aggiunto quando ti riconnetti", + "addToQueue": "Aggiungi alla coda", + "editRelease": "Modifica rilascio", + "goToRelease": "Vai a rilascio", + "sortBy": "Ordina per", + "selectedCount": "{count, plural, =1 {1 selezionato} other {{count} selezionati}}", + "confirmDeleteTitle": "{count, plural, =1 {Eliminare il rilascio?} other {Eliminare {count} rilasci?}}", + "confirmDeleteMessage": "Questo non può essere annullato.", "previewNotAvailable": "Anteprima non disponibile per questa fonte", "columns": { "artistTitle": "Artista / Titolo", @@ -289,11 +334,17 @@ }, "fetchingMetadata": "Recupero metadati...", "refreshMetadata": "Aggiorna metadati", + "downloadForOffline": "Scarica per offline", + "downloading": "Download in corso...", + "downloadedForOffline": "Scaricato", + "removeDownload": "Rimuovi download", + "downloadFailed": "Impossibile scaricare per offline", "fetchError": "Impossibile recuperare i metadati da questo URL", "unsupportedUrl": "Questo URL non è supportato", "tracks": "Tracce", "trackCount": "{count, plural, =1 {1 traccia} other {{count} tracce}}", "openInBrowser": "Apri nel browser", + "openInApp": "Apri in {app}", "copyUrl": "Copia URL", "copiedUrl": "URL copiato negli appunti", "like": "Aggiungi ai preferiti", @@ -312,7 +363,9 @@ "title": "Titolo", "label": "Etichetta", "releaseDate": "Data di rilascio", - "notes": "Note" + "notes": "Note", + "notesPlaceholder": "Aggiungi note…", + "addTags": "Aggiungi tag" }, "import": { "title": "Importa nella libreria", @@ -423,6 +476,11 @@ "rootNoFolder": "Radice (nessuna cartella)", "folderEmpty": "Questa cartella è vuota", "noPlaylistsYet": "Nessuna playlist per ora", + "noPlaylists": "Nessuna playlist per ora", + "allPlaylists": "Tutte le playlist", + "searchPlaceholder": "Cerca playlist…", + "emptyHint": "Crea playlist per organizzare le tue scoperte", + "detailEmptyHint": "Tieni premuto un rilascio in Scoperte per aggiungerlo a una playlist", "exportToDevice": "Esporta su dispositivo" }, "tags": { @@ -436,7 +494,8 @@ "addTag": "Aggiungi tag", "moveToCategory": "Sposta nella categoria", "noTags": "Nessun tag", - "noTagCategoriesYet": "Nessuna categoria di tag per ora" + "noTagCategoriesYet": "Nessuna categoria di tag per ora", + "emptyHint": "Crea categorie di tag con codice colore per organizzare le tue scoperte" }, "contextMenu": { "viewInFinder": "Visualizza nel Finder", @@ -701,6 +760,9 @@ "emerald": "Smeraldo" }, "dates": { + "justNow": "Proprio ora", + "minutesAgo": "{count, plural, =1 {1 minuto fa} other {{count} minuti fa}}", + "hoursAgo": "{count, plural, =1 {1 ora fa} other {{count} ore fa}}", "today": "Oggi", "yesterday": "Ieri", "daysAgo": "{count, plural, =1 {1 giorno fa} other {{count} giorni fa}}", @@ -891,6 +953,26 @@ "title": "Tutto è pronto!", "subtitle": "Inizia a costruire la tua collezione." }, - "getStarted": "Inizia" + "getStarted": "Inizia", + "mobile": { + "welcome": { + "title": "Benvenuto in {appName}", + "subtitle": "Scopri musica e costruisci la tua collezione — direttamente dalla tua tasca." + }, + "discover": { + "title": "Aggiungi e anteprima le uscite", + "description": "Incolla un link da Bandcamp, SoundCloud e altro per salvare un'uscita, quindi tocca per un'anteprima istantanea." + }, + "sync": { + "title": "Sincronizza con il tuo desktop", + "description": "Accedi facoltativamente per mantenere la tua collezione sincronizzata su tutti i tuoi dispositivi. Puoi farlo anche in seguito." + }, + "appearance": { + "title": "Rendilo tuo", + "description": "Scegli un tema e un colore di accento. Puoi cambiarli in qualsiasi momento in Impostazioni." + }, + "skip": "Salta", + "maybeLater": "Forse più tardi" + } } } diff --git a/src/lib/i18n/locales/ja.json b/shared/i18n/locales/ja.json similarity index 90% rename from src/lib/i18n/locales/ja.json rename to shared/i18n/locales/ja.json index 5a509260..84d4705a 100644 --- a/src/lib/i18n/locales/ja.json +++ b/shared/i18n/locales/ja.json @@ -1,5 +1,6 @@ { "common": { + "add": "追加", "close": "閉じる", "save": "保存", "search": "検索...", @@ -20,13 +21,18 @@ "folder": "フォルダ", "playlist": "プレイリスト", "next": "次へ", - "back": "戻る" + "back": "戻る", + "more": "その他", + "select": "選択", + "noResults": "結果なし" }, "nav": { "library": "ライブラリ", "discovery": "ディスカバリー", "playlists": "プレイリスト", - "tags": "タグ" + "tags": "タグ", + "openMenu": "メニューを開く", + "openSettings": "設定を開く" }, "settings": { "title": "設定", @@ -117,7 +123,12 @@ "cacheSize": "キャッシュ サイズ", "clearCache": "キャッシュをクリア", "clearCacheConfirmMessage": "キャッシュされたすべてのプレビュー オーディオをクリアしてもよろしいですか?", - "clearCacheWarning": "以前プレビューされたトラックは、次の再生時にソースから再度ストリーミングする必要があります。これはより遅くなり、一部のストリームは利用できなくなる可能性があります。" + "clearCacheWarning": "以前プレビューされたトラックは、次の再生時にソースから再度ストリーミングする必要があります。これはより遅くなり、一部のストリームは利用できなくなる可能性があります。", + "audioCache": "オーディオ", + "artworkCache": "アートワーク", + "artworkCacheDescription": "アルバムアートはこのデバイスに保存され、接続がなくても表示されます。", + "clearArtworkCacheConfirmMessage": "キャッシュされたすべてのアルバムアートをクリアしてもよろしいですか?", + "cacheLimit": "キャッシュ制限" }, "following": { "title": "フォロー中", @@ -179,7 +190,8 @@ "updates": "アップデート", "checkForUpdates": "アップデートを確認", "upToDate": "最新版です", - "updateAvailable": "{version} が利用可能" + "updateAvailable": "{version} が利用可能", + "project": "プロジェクト" } }, "cloudSync": { @@ -200,6 +212,9 @@ "syncNow": "今すぐ同期", "syncing": "同期中…", "signOut": "サインアウト", + "signOutConfirmTitle": "サインアウトしますか?", + "signOutConfirmMessage": "ローカルデータはこのデバイスに残ります。再びサインインするまで同期は停止します。", + "autoSyncHint": "Discovery ライブラリはすべてのデバイス間で自動的に同期されます。", "lastSynced": "最後に同期した {time}" }, "devices": { @@ -245,7 +260,26 @@ "volume": "音量", "tempo": "テンポ", "resetTempo": "テンポをリセット", - "trackNotFound": "再生中のトラックが見つかりません" + "trackNotFound": "再生中のトラックが見つかりません", + "expand": "プレイヤーを展開", + "collapse": "プレイヤーを折りたたむ", + "seek": "シーク" + }, + "queue": { + "upNext": "次に再生", + "openQueue": "次に再生", + "playNext": "次に再生", + "addToQueue": "キューに追加", + "playingNext": "次に再生中", + "addedToQueue": "キューに追加されました", + "nextInQueue": "キューの次", + "upNextFromContext": "次に再生", + "addedByYou": "あなたが追加", + "removeFromQueue": "キューから削除", + "reorder": "並べ替え", + "clearQueue": "クリア", + "empty": "次はありません", + "emptyHint": "追加した曲が次に再生されます" }, "library": { "searchPlaceholder": "トラックを検索...", @@ -277,7 +311,18 @@ "releases": "リリース", "searchPlaceholder": "リリースを検索...", "noReleasesYet": "まだリリースがありません", + "noResults": "フィルターに合致するリリースがありません", "addReleaseHint": "{shortcut} を押して Bandcamp、Discogs、YouTube などからリリースを追加してください", + "mobileAddHint": "Bandcamp、Discogs、YouTube などからリリースを追加してください", + "pending": "保留中", + "offlineQueueNotice": "オフラインです — 再度接続したときにこのリリースが追加されます", + "addToQueue": "キューに追加", + "editRelease": "リリースを編集", + "goToRelease": "リリースに移動", + "sortBy": "並べ替え", + "selectedCount": "{count, plural, =1 {1個選択} other {{count}個選択}}", + "confirmDeleteTitle": "{count, plural, =1 {リリースを削除しますか?} other {{count}個のリリースを削除しますか?}}", + "confirmDeleteMessage": "この操作は元に戻せません。", "previewNotAvailable": "このソースではプレビューを利用できません", "columns": { "artistTitle": "アーティスト / タイトル", @@ -289,11 +334,17 @@ }, "fetchingMetadata": "メタデータを取得中...", "refreshMetadata": "メタデータを更新", + "downloadForOffline": "オフラインでダウンロード", + "downloading": "ダウンロード中...", + "downloadedForOffline": "ダウンロード済み", + "removeDownload": "ダウンロードを削除", + "downloadFailed": "オフライン用のダウンロードに失敗しました", "fetchError": "このURLからメタデータを取得できませんでした", "unsupportedUrl": "このURLはサポートされていません", "tracks": "トラック", "trackCount": "{count, plural, =1 {1曲} other {{count}曲}}", "openInBrowser": "ブラウザで開く", + "openInApp": "{app}で開く", "copyUrl": "URLをコピー", "copiedUrl": "URLをコピーしました", "like": "お気に入りに追加", @@ -312,7 +363,9 @@ "title": "タイトル", "label": "レーベル", "releaseDate": "リリース日", - "notes": "メモ" + "notes": "メモ", + "notesPlaceholder": "メモを追加…", + "addTags": "タグを追加" }, "import": { "title": "ライブラリにインポート", @@ -423,6 +476,11 @@ "rootNoFolder": "ルート(フォルダなし)", "folderEmpty": "このフォルダは空です", "noPlaylistsYet": "まだプレイリストがありません", + "noPlaylists": "まだプレイリストがありません", + "allPlaylists": "すべてのプレイリスト", + "searchPlaceholder": "プレイリストを検索…", + "emptyHint": "プレイリストを作成してディスカバリーを整理しましょう", + "detailEmptyHint": "ディスカバリーのリリースを長押ししてプレイリストに追加してください", "exportToDevice": "デバイスにエクスポート" }, "tags": { @@ -436,7 +494,8 @@ "addTag": "タグを追加", "moveToCategory": "カテゴリに移動", "noTags": "タグなし", - "noTagCategoriesYet": "まだタグカテゴリがありません" + "noTagCategoriesYet": "まだタグカテゴリがありません", + "emptyHint": "色分けされたタグカテゴリを作成してディスカバリーを整理しましょう" }, "contextMenu": { "viewInFinder": "Finderで表示", @@ -702,6 +761,9 @@ "emerald": "エメラルド" }, "dates": { + "justNow": "今しがた", + "minutesAgo": "{count, plural, =1 {1分前} other {{count}分前}}", + "hoursAgo": "{count, plural, =1 {1時間前} other {{count}時間前}}", "today": "今日", "yesterday": "昨日", "daysAgo": "{count, plural, =1 {1日前} other {{count}日前}}", @@ -892,6 +954,26 @@ "title": "準備完了です!", "subtitle": "コレクションの構築を開始します。" }, - "getStarted": "はじめる" + "getStarted": "はじめる", + "mobile": { + "welcome": { + "title": "{appName}へようこそ", + "subtitle": "音楽を発見し、コレクションを構築してください — ポケットから直接。" + }, + "discover": { + "title": "リリースを追加してプレビュー", + "description": "Bandcamp、SoundCloud などからリンクを貼り付けてリリースを保存し、タップして即座にプレビューします。" + }, + "sync": { + "title": "デスクトップと同期", + "description": "サインインして、すべてのデバイス間でコレクションを同期に保ちます。後でいつでも実行できます。" + }, + "appearance": { + "title": "自分好みにしよう", + "description": "テーマとアクセントカラーを選択します。設定でいつでも変更できます。" + }, + "skip": "スキップ", + "maybeLater": "後で" + } } } diff --git a/src/lib/i18n/locales/ko.json b/shared/i18n/locales/ko.json similarity index 90% rename from src/lib/i18n/locales/ko.json rename to shared/i18n/locales/ko.json index d1a5b635..f4b87367 100644 --- a/src/lib/i18n/locales/ko.json +++ b/shared/i18n/locales/ko.json @@ -1,5 +1,6 @@ { "common": { + "add": "추가", "close": "닫기", "save": "저장", "search": "검색...", @@ -20,13 +21,18 @@ "folder": "폴더", "playlist": "재생목록", "next": "다음", - "back": "뒤로" + "back": "뒤로", + "more": "더 보기", + "select": "선택", + "noResults": "결과 없음" }, "nav": { "library": "라이브러리", "discovery": "발견", "playlists": "재생목록", - "tags": "태그" + "tags": "태그", + "openMenu": "메뉴 열기", + "openSettings": "설정 열기" }, "settings": { "title": "설정", @@ -117,7 +123,12 @@ "cacheSize": "캐시 크기", "clearCache": "캐시 비우기", "clearCacheConfirmMessage": "캐시된 모든 미리보기 오디오를 비우시겠습니까?", - "clearCacheWarning": "이전에 미리본 트랙은 다음 재생 시 소스에서 다시 스트리밍해야 합니다. 이것이 더 느릴 수 있고 일부 스트림은 더 이상 사용할 수 없을 수 있습니다." + "clearCacheWarning": "이전에 미리본 트랙은 다음 재생 시 소스에서 다시 스트리밍해야 합니다. 이것이 더 느릴 수 있고 일부 스트림은 더 이상 사용할 수 없을 수 있습니다.", + "audioCache": "오디오", + "artworkCache": "앨범 아트", + "artworkCacheDescription": "앨범 아트는 이 기기에 저장되므로 연결 없이도 표시됩니다.", + "clearArtworkCacheConfirmMessage": "캐시된 모든 앨범 아트를 비우시겠습니까?", + "cacheLimit": "캐시 제한" }, "following": { "title": "팔로잉", @@ -179,7 +190,8 @@ "updates": "업데이트", "checkForUpdates": "업데이트 확인", "upToDate": "최신 상태", - "updateAvailable": "{version} 사용 가능" + "updateAvailable": "{version} 사용 가능", + "project": "프로젝트" } }, "cloudSync": { @@ -200,6 +212,9 @@ "syncNow": "지금 동기화", "syncing": "동기화 중…", "signOut": "로그아웃", + "signOutConfirmTitle": "로그아웃하시겠습니까?", + "signOutConfirmMessage": "로컬 데이터는 이 디바이스에 유지됩니다. 다시 로그인할 때까지 동기화가 중지됩니다.", + "autoSyncHint": "Discovery 라이브러리는 모든 디바이스에서 자동으로 동기화됩니다.", "lastSynced": "마지막 동기화 {time}" }, "devices": { @@ -245,7 +260,26 @@ "volume": "볼륨", "tempo": "템포", "resetTempo": "템포 재설정", - "trackNotFound": "재생 중인 트랙을 찾을 수 없습니다" + "trackNotFound": "재생 중인 트랙을 찾을 수 없습니다", + "expand": "플레이어 확장", + "collapse": "플레이어 축소", + "seek": "탐색" + }, + "queue": { + "upNext": "다음 곡", + "openQueue": "다음 곡", + "playNext": "다음 재생", + "addToQueue": "대기열에 추가", + "playingNext": "다음 재생 중", + "addedToQueue": "대기열에 추가됨", + "nextInQueue": "대기열의 다음", + "upNextFromContext": "다음 곡", + "addedByYou": "당신이 추가함", + "removeFromQueue": "대기열에서 제거", + "reorder": "다시 정렬", + "clearQueue": "지우기", + "empty": "다음이 없습니다", + "emptyHint": "추가하는 곡이 다음에 재생됩니다" }, "library": { "searchPlaceholder": "트랙 검색...", @@ -277,7 +311,18 @@ "releases": "릴리스", "searchPlaceholder": "릴리스 검색...", "noReleasesYet": "아직 릴리스가 없습니다", + "noResults": "필터와 일치하는 릴리스가 없습니다", "addReleaseHint": "{shortcut} 를 눌러 Bandcamp, Discogs, YouTube 등에서 릴리스를 추가하세요", + "mobileAddHint": "Bandcamp, Discogs, YouTube 등에서 릴리스 추가", + "pending": "대기 중", + "offlineQueueNotice": "오프라인 상태입니다 — 다시 연결하면 이 릴리스가 추가됩니다", + "addToQueue": "대기열에 추가", + "editRelease": "릴리스 편집", + "goToRelease": "릴리스로 이동", + "sortBy": "정렬 기준", + "selectedCount": "{count, plural, =1 {1개 선택} other {{count}개 선택}}", + "confirmDeleteTitle": "{count, plural, =1 {릴리스를 삭제하시겠습니까?} other {{count}개의 릴리스를 삭제하시겠습니까?}}", + "confirmDeleteMessage": "이 작업은 취소할 수 없습니다.", "previewNotAvailable": "이 소스에서는 미리듣기를 사용할 수 없습니다", "columns": { "artistTitle": "아티스트 / 제목", @@ -289,11 +334,17 @@ }, "fetchingMetadata": "메타데이터 가져오는 중...", "refreshMetadata": "메타데이터 새로 고침", + "downloadForOffline": "오프라인용 다운로드", + "downloading": "다운로드 중...", + "downloadedForOffline": "다운로드됨", + "removeDownload": "다운로드 제거", + "downloadFailed": "오프라인용 다운로드에 실패했습니다", "fetchError": "이 URL에서 메타데이터를 가져올 수 없습니다", "unsupportedUrl": "이 URL은 지원되지 않습니다", "tracks": "트랙", "trackCount": "{count, plural, =1 {1개의 트랙} other {{count}개의 트랙}}", "openInBrowser": "브라우저에서 열기", + "openInApp": "{app}에서 열기", "copyUrl": "URL 복사", "copiedUrl": "URL이 클립보드에 복사되었습니다", "like": "좋아요 추가", @@ -312,7 +363,9 @@ "title": "제목", "label": "레이블", "releaseDate": "릴리스 날짜", - "notes": "노트" + "notes": "노트", + "notesPlaceholder": "메모 추가…", + "addTags": "태그 추가" }, "import": { "title": "라이브러리로 가져오기", @@ -423,6 +476,11 @@ "rootNoFolder": "루트 (폴더 없음)", "folderEmpty": "이 폴더가 비어 있습니다", "noPlaylistsYet": "아직 재생목록이 없습니다", + "noPlaylists": "아직 재생목록이 없습니다", + "allPlaylists": "모든 재생목록", + "searchPlaceholder": "재생목록 검색…", + "emptyHint": "재생목록을 만들어 발견을 정리하세요", + "detailEmptyHint": "발견에서 릴리스를 길게 눌러 재생목록에 추가하세요", "exportToDevice": "장치로 내보내기" }, "tags": { @@ -436,7 +494,8 @@ "addTag": "태그 추가", "moveToCategory": "카테고리로 이동", "noTags": "태그 없음", - "noTagCategoriesYet": "아직 태그 카테고리가 없습니다" + "noTagCategoriesYet": "아직 태그 카테고리가 없습니다", + "emptyHint": "색상으로 분류된 태그 카테고리를 만들어 발견을 정리하세요" }, "contextMenu": { "viewInFinder": "Finder에서 보기", @@ -701,6 +760,9 @@ "emerald": "에메랄드" }, "dates": { + "justNow": "방금", + "minutesAgo": "{count, plural, =1 {1분 전} other {{count}분 전}}", + "hoursAgo": "{count, plural, =1 {1시간 전} other {{count}시간 전}}", "today": "오늘", "yesterday": "어제", "daysAgo": "{count, plural, =1 {1일 전} other {{count}일 전}}", @@ -891,6 +953,26 @@ "title": "모두 설정되었습니다!", "subtitle": "컬렉션 구축을 시작합니다." }, - "getStarted": "시작하기" + "getStarted": "시작하기", + "mobile": { + "welcome": { + "title": "{appName}에 오신 것을 환영합니다", + "subtitle": "음악을 발견하고 컬렉션을 구축하세요 — 주머니에서 바로." + }, + "discover": { + "title": "릴리스 추가 및 미리보기", + "description": "Bandcamp, SoundCloud 등에서 링크를 붙여넣어 릴리스를 저장한 다음 탭하여 즉시 미리보기합니다." + }, + "sync": { + "title": "데스크톱과 동기화", + "description": "선택적으로 로그인하여 모든 기기에서 컬렉션을 동기화 상태로 유지합니다. 나중에 언제든지 할 수 있습니다." + }, + "appearance": { + "title": "당신의 것으로 만드세요", + "description": "테마와 강조색을 선택하세요. 설정에서 언제든지 변경할 수 있습니다." + }, + "skip": "건너뛰기", + "maybeLater": "나중에" + } } } diff --git a/src/lib/i18n/locales/nl.json b/shared/i18n/locales/nl.json similarity index 89% rename from src/lib/i18n/locales/nl.json rename to shared/i18n/locales/nl.json index 2ec3444c..68ab0248 100644 --- a/src/lib/i18n/locales/nl.json +++ b/shared/i18n/locales/nl.json @@ -1,5 +1,6 @@ { "common": { + "add": "Toevoegen", "close": "Sluiten", "save": "Opslaan", "search": "Zoeken...", @@ -20,13 +21,18 @@ "folder": "map", "playlist": "afspeellijst", "next": "Volgende", - "back": "Terug" + "back": "Terug", + "more": "Meer", + "select": "Selecteren", + "noResults": "Geen resultaten" }, "nav": { "library": "Bibliotheek", "discovery": "Ontdekkingen", "playlists": "Afspeellijsten", - "tags": "Tags" + "tags": "Tags", + "openMenu": "Menu openen", + "openSettings": "Instellingen openen" }, "settings": { "title": "Instellingen", @@ -117,7 +123,12 @@ "cacheSize": "Cachegrootte", "clearCache": "Cache wissen", "clearCacheConfirmMessage": "Weet je zeker dat je alle cachegeheugen voor voorbeeldaudio wilt wissen?", - "clearCacheWarning": "Eerder bekeken nummers moeten bij volgende afspeling opnieuw van hun bron worden gestreamd. Dit kan trager zijn en sommige streams zijn mogelijk niet meer beschikbaar." + "clearCacheWarning": "Eerder bekeken nummers moeten bij volgende afspeling opnieuw van hun bron worden gestreamd. Dit kan trager zijn en sommige streams zijn mogelijk niet meer beschikbaar.", + "audioCache": "Audio", + "artworkCache": "Albumhoes", + "artworkCacheDescription": "Albumhoezen worden op dit apparaat opgeslagen zodat ze zonder verbinding kunnen worden weergegeven.", + "clearArtworkCacheConfirmMessage": "Weet je zeker dat je alle cachegeheugen voor albumhoezen wilt wissen?", + "cacheLimit": "Cachemimiet" }, "following": { "title": "Volgend", @@ -179,7 +190,8 @@ "updates": "Updates", "checkForUpdates": "Op updates controleren", "upToDate": "Up-to-date", - "updateAvailable": "{version} beschikbaar" + "updateAvailable": "{version} beschikbaar", + "project": "Project" } }, "cloudSync": { @@ -200,6 +212,9 @@ "syncNow": "Nu synchroniseren", "syncing": "Synchroniseren…", "signOut": "Afmelden", + "signOutConfirmTitle": "Afmelden?", + "signOutConfirmMessage": "Uw lokale gegevens blijven op dit apparaat. Synchronisatie stopt totdat u zich opnieuw aanmeldt.", + "autoSyncHint": "Uw Discovery-bibliotheek wordt automatisch gesynchroniseerd op al uw apparaten.", "lastSynced": "Laatst gesynchroniseerd {time}" }, "devices": { @@ -245,7 +260,26 @@ "volume": "Volume", "tempo": "Tempo", "resetTempo": "Tempo resetten", - "trackNotFound": "Kon het afspelende nummer niet vinden" + "trackNotFound": "Kon het afspelende nummer niet vinden", + "expand": "Speler uitvouwen", + "collapse": "Speler samenvouwen", + "seek": "Zoeken" + }, + "queue": { + "upNext": "Volgende", + "openQueue": "Volgende", + "playNext": "Volgende afspelen", + "addToQueue": "Toevoegen aan wachtrij", + "playingNext": "Volgende afspelen", + "addedToQueue": "Toegevoegd aan wachtrij", + "nextInQueue": "Volgende in wachtrij", + "upNextFromContext": "Volgende", + "addedByYou": "Toegevoegd door jou", + "removeFromQueue": "Verwijderen uit wachtrij", + "reorder": "Opnieuw ordenen", + "clearQueue": "Wissen", + "empty": "Niets volgende", + "emptyHint": "Nummers die je toevoegt worden hierna afgespeeld" }, "library": { "searchPlaceholder": "Tracks zoeken...", @@ -277,7 +311,18 @@ "releases": "releases", "searchPlaceholder": "Releases zoeken...", "noReleasesYet": "Nog geen releases", + "noResults": "Geen releases komen overeen met je filters", "addReleaseHint": "Druk {shortcut} om releases van Bandcamp, Discogs, YouTube en meer toe te voegen", + "mobileAddHint": "Releases van Bandcamp, Discogs, YouTube en meer toevoegen", + "pending": "In behandeling", + "offlineQueueNotice": "Je bent offline — deze release wordt toegevoegd als je verbonden bent", + "addToQueue": "Toevoegen aan wachtrij", + "editRelease": "Release bewerken", + "goToRelease": "Ga naar Release", + "sortBy": "Sorteren op", + "selectedCount": "{count, plural, =1 {1 geselecteerd} other {{count} geselecteerd}}", + "confirmDeleteTitle": "{count, plural, =1 {Release verwijderen?} other {{count} releases verwijderen?}}", + "confirmDeleteMessage": "Dit kan niet ongedaan gemaakt worden.", "previewNotAvailable": "Voorbeeld niet beschikbaar voor deze bron", "columns": { "artistTitle": "Artiest / Titel", @@ -289,11 +334,17 @@ }, "fetchingMetadata": "Metagegevens ophalen...", "refreshMetadata": "Metagegevens vernieuwen", + "downloadForOffline": "Voor offline downloaden", + "downloading": "Bezig met downloaden...", + "downloadedForOffline": "Gedownload", + "removeDownload": "Download verwijderen", + "downloadFailed": "Kon niet downloaden voor offline", "fetchError": "Kan metagegevens niet ophalen van deze URL", "unsupportedUrl": "Deze URL wordt niet ondersteund", "tracks": "Tracks", "trackCount": "{count, plural, =1 {1 track} other {{count} tracks}}", "openInBrowser": "In browser openen", + "openInApp": "In {app} openen", "copyUrl": "URL kopiëren", "copiedUrl": "URL gekopieerd naar klembord", "like": "Toevoegen aan favorieten", @@ -312,7 +363,9 @@ "title": "Titel", "label": "Label", "releaseDate": "Releasedatum", - "notes": "Opmerkingen" + "notes": "Opmerkingen", + "notesPlaceholder": "Opmerkingen toevoegen…", + "addTags": "Tags toevoegen" }, "import": { "title": "Importeren naar bibliotheek", @@ -423,6 +476,11 @@ "rootNoFolder": "Hoofdmap (geen map)", "folderEmpty": "Deze map is leeg", "noPlaylistsYet": "Nog geen afspeellijsten", + "noPlaylists": "Nog geen afspeellijsten", + "allPlaylists": "Alle afspeellijsten", + "searchPlaceholder": "Afspeellijsten zoeken…", + "emptyHint": "Maak afspeellijsten om je ontdekkingen in te delen", + "detailEmptyHint": "Houd een release in Ontdekkingen ingedrukt om het aan een afspeellijst toe te voegen", "exportToDevice": "Exporteren naar apparaat" }, "tags": { @@ -436,7 +494,8 @@ "addTag": "Tag toevoegen", "moveToCategory": "Verplaatsen naar categorie", "noTags": "Geen tags", - "noTagCategoriesYet": "Nog geen tagcategorieën" + "noTagCategoriesYet": "Nog geen tagcategorieën", + "emptyHint": "Maak kleurgecodeerde tagcategorieën om je ontdekkingen in te delen" }, "contextMenu": { "viewInFinder": "Bekijken in Finder", @@ -701,6 +760,9 @@ "emerald": "Smaragd" }, "dates": { + "justNow": "Zojuist", + "minutesAgo": "{count, plural, =1 {1 minuut geleden} other {{count} minuten geleden}}", + "hoursAgo": "{count, plural, =1 {1 uur geleden} other {{count} uren geleden}}", "today": "Vandaag", "yesterday": "Gisteren", "daysAgo": "{count, plural, =1 {1 dag geleden} other {{count} dagen geleden}}", @@ -891,6 +953,26 @@ "title": "Je bent helemaal klaar!", "subtitle": "Begin met het opbouwen van je collectie." }, - "getStarted": "Aan de slag" + "getStarted": "Aan de slag", + "mobile": { + "welcome": { + "title": "Welkom bij {appName}", + "subtitle": "Ontdek muziek en bouw je collectie op — direct vanuit je zak." + }, + "discover": { + "title": "Voeg releases toe en bekijk een voorbeeld", + "description": "Plak een link van Bandcamp, SoundCloud en meer om een release op te slaan, tik vervolgens om direct een voorbeeld te bekijken." + }, + "sync": { + "title": "Synchroniseer met je bureaublad", + "description": "Meld je optioneel aan om je collectie op al je apparaten gesynchroniseerd te houden. Je kunt dit altijd later doen." + }, + "appearance": { + "title": "Maak het van jou", + "description": "Kies een thema en een accentkleur. Je kunt deze op elk moment in Instellingen wijzigen." + }, + "skip": "Overslaan", + "maybeLater": "Misschien later" + } } } diff --git a/src/lib/i18n/locales/pl.json b/shared/i18n/locales/pl.json similarity index 90% rename from src/lib/i18n/locales/pl.json rename to shared/i18n/locales/pl.json index b3b2536e..e03c3c49 100644 --- a/src/lib/i18n/locales/pl.json +++ b/shared/i18n/locales/pl.json @@ -1,5 +1,6 @@ { "common": { + "add": "Dodaj", "close": "Zamknij", "save": "Zapisz", "search": "Szukaj...", @@ -20,13 +21,18 @@ "folder": "folder", "playlist": "playlista", "next": "Dalej", - "back": "Wstecz" + "back": "Wstecz", + "more": "Więcej", + "select": "Wybierz", + "noResults": "Brak wyników" }, "nav": { "library": "Biblioteka", "discovery": "Odkrywanie", "playlists": "Playlisty", - "tags": "Tagi" + "tags": "Tagi", + "openMenu": "Otwórz menu", + "openSettings": "Otwórz ustawienia" }, "settings": { "title": "Ustawienia", @@ -117,7 +123,12 @@ "cacheSize": "Rozmiar buforu", "clearCache": "Wyczyść bufor", "clearCacheConfirmMessage": "Czy na pewno chcesz wyczyścić wszystkie buforowane pliki audio z podglądem?", - "clearCacheWarning": "Poprzednio słuchane ścieżki będą musiały być ponownie przesyłane ze źródła przy następnym odtwarzaniu. Może to być wolniejsze i niektóre strumienie mogą już nie być dostępne." + "clearCacheWarning": "Poprzednio słuchane ścieżki będą musiały być ponownie przesyłane ze źródła przy następnym odtwarzaniu. Może to być wolniejsze i niektóre strumienie mogą już nie być dostępne.", + "audioCache": "Audio", + "artworkCache": "Okładka", + "artworkCacheDescription": "Okładki albumów są zapisywane na tym urządzeniu, aby wyświetlały się bez połączenia.", + "clearArtworkCacheConfirmMessage": "Czy na pewno chcesz wyczyścić wszystkie buforowane okładki albumów?", + "cacheLimit": "Limit buforu" }, "following": { "title": "Obserwowani", @@ -179,7 +190,8 @@ "updates": "Aktualizacje", "checkForUpdates": "Sprawdź dostępne aktualizacje", "upToDate": "Aktualna", - "updateAvailable": "Dostępna wersja {version}" + "updateAvailable": "Dostępna wersja {version}", + "project": "Projekt" } }, "cloudSync": { @@ -200,6 +212,9 @@ "syncNow": "Synchronizuj teraz", "syncing": "Synchronizowanie…", "signOut": "Wyloguj", + "signOutConfirmTitle": "Wylogować się?", + "signOutConfirmMessage": "Twoje dane lokalne pozostają na tym urządzeniu. Synchronizacja zostanie wznowiona po ponownym zalogowaniu.", + "autoSyncHint": "Twoja biblioteka Discovery synchronizuje się automatycznie na wszystkich twoich urządzeniach.", "lastSynced": "Ostatnia synchronizacja {time}" }, "devices": { @@ -245,7 +260,26 @@ "volume": "Głośność", "tempo": "Tempo", "resetTempo": "Resetuj tempo", - "trackNotFound": "Nie udało się zlokalizować odtwarzanego utworu" + "trackNotFound": "Nie udało się zlokalizować odtwarzanego utworu", + "expand": "Rozwiń odtwarzacz", + "collapse": "Zwiń odtwarzacz", + "seek": "Szukaj" + }, + "queue": { + "upNext": "Kolejne", + "openQueue": "Kolejne", + "playNext": "Odtwórz następne", + "addToQueue": "Dodaj do kolejki", + "playingNext": "Odtwarzanie następne", + "addedToQueue": "Dodane do kolejki", + "nextInQueue": "Następne w kolejce", + "upNextFromContext": "Kolejne", + "addedByYou": "Dodane przez ciebie", + "removeFromQueue": "Usuń z kolejki", + "reorder": "Zmień kolejność", + "clearQueue": "Wyczyść", + "empty": "Nic kolejnego", + "emptyHint": "Utwory, które dodasz będą odtwarzane dalej" }, "library": { "searchPlaceholder": "Szukaj ścieżek...", @@ -277,7 +311,18 @@ "releases": "wydań", "searchPlaceholder": "Szukaj wydań...", "noReleasesYet": "Brak wydań", + "noResults": "Żadne wydania nie pasują do twoich filtrów", "addReleaseHint": "Naciśnij {shortcut}, aby dodać wydania z Bandcamp, Discogs, YouTube i innych", + "mobileAddHint": "Dodaj wydania z Bandcamp, Discogs, YouTube i innych", + "pending": "Oczekujące", + "offlineQueueNotice": "Jesteś w trybie offline — to wydanie zostanie dodane po ponownym połączeniu", + "addToQueue": "Dodaj do kolejki", + "editRelease": "Edytuj wydanie", + "goToRelease": "Przejdź do wydania", + "sortBy": "Sortuj według", + "selectedCount": "{count, plural, =1 {1 zaznaczony} other {{count} zaznaczonych}}", + "confirmDeleteTitle": "{count, plural, =1 {Usunąć wydanie?} other {Usunąć {count} wydań?}}", + "confirmDeleteMessage": "Tej czynności nie można cofnąć.", "previewNotAvailable": "Podgląd nie jest dostępny dla tego źródła", "columns": { "artistTitle": "Artysta / Tytuł", @@ -289,10 +334,16 @@ }, "fetchingMetadata": "Pobieranie metadanych...", "refreshMetadata": "Odśwież metadane", + "downloadForOffline": "Pobierz do użytku offline", + "downloading": "Pobieranie...", + "downloadedForOffline": "Pobrane", + "removeDownload": "Usuń pobranie", + "downloadFailed": "Nie udało się pobrać do użytku offline", "fetchError": "Nie można pobrać metadanych z tego adresu URL", "tracks": "Ścieżki", "trackCount": "{count, plural, =1 {1 ścieżka} other {{count} ścieżek}}", "openInBrowser": "Otwórz w przeglądarce", + "openInApp": "Otwórz w {app}", "copyUrl": "Skopiuj URL", "copiedUrl": "URL skopiowany do schowka", "like": "Dodaj do polubionych", @@ -311,7 +362,9 @@ "title": "Tytuł", "label": "Label", "releaseDate": "Data wydania", - "notes": "Notatki" + "notes": "Notatki", + "notesPlaceholder": "Dodaj notatki…", + "addTags": "Dodaj tagi" }, "import": { "title": "Importuj do biblioteki", @@ -422,6 +475,11 @@ "rootNoFolder": "Root (Bez folderu)", "folderEmpty": "Ten folder jest pusty", "noPlaylistsYet": "Brak playlist", + "noPlaylists": "Brak playlist", + "allPlaylists": "Wszystkie playlisty", + "searchPlaceholder": "Szukaj playlist…", + "emptyHint": "Utwórz playlisty, aby zorganizować swoje odkrycia", + "detailEmptyHint": "Naciśnij i przytrzymaj wydanie w Odkrywaniu, aby dodać je do playlisty", "exportToDevice": "Eksportuj na urządzenie" }, "tags": { @@ -435,7 +493,8 @@ "addTag": "Dodaj tag", "moveToCategory": "Przenieś do kategorii", "noTags": "Brak tagów", - "noTagCategoriesYet": "Brak kategorii tagów" + "noTagCategoriesYet": "Brak kategorii tagów", + "emptyHint": "Utwórz kategorie tagów z kodowaniem kolorami, aby zorganizować swoje odkrycia" }, "contextMenu": { "viewInFinder": "Pokaż w Finderze", @@ -701,6 +760,9 @@ "emerald": "Szmaragdowy" }, "dates": { + "justNow": "Przed chwilą", + "minutesAgo": "{count, plural, =1 {1 minutę temu} other {{count} minut temu}}", + "hoursAgo": "{count, plural, =1 {1 godzinę temu} other {{count} godzin temu}}", "today": "Dzisiaj", "yesterday": "Wczoraj", "daysAgo": "{count, plural, =1 {1 dzień temu} other {{count} dni temu}}", @@ -888,6 +950,26 @@ "title": "Wszystko gotowe!", "subtitle": "Zacznij budować swoją kolekcję." }, - "getStarted": "Zacznij" + "getStarted": "Zacznij", + "mobile": { + "welcome": { + "title": "Witaj w {appName}", + "subtitle": "Odkrywaj muzykę i buduj swoją kolekcję — bezpośrednio z kieszeni." + }, + "discover": { + "title": "Dodaj i podgląd wydawnictw", + "description": "Wklej link z Bandcamp, SoundCloud i więcej, aby zapisać wydawnictwo, a następnie dotknij, aby uzyskać natychmiastowy podgląd." + }, + "sync": { + "title": "Synchronizuj z pulpitem", + "description": "Opcjonalnie zaloguj się, aby utrzymać kolekcję zsynchronizowaną na wszystkich urządzeniach. Możesz zrobić to później." + }, + "appearance": { + "title": "Uczyń to swoim", + "description": "Wybierz motyw i kolor akcentu. W każdej chwili możesz zmienić je w Ustawieniach." + }, + "skip": "Pomiń", + "maybeLater": "Może później" + } } } \ No newline at end of file diff --git a/src/lib/i18n/locales/pt.json b/shared/i18n/locales/pt.json similarity index 90% rename from src/lib/i18n/locales/pt.json rename to shared/i18n/locales/pt.json index b7a7c921..69fd432d 100644 --- a/src/lib/i18n/locales/pt.json +++ b/shared/i18n/locales/pt.json @@ -1,5 +1,6 @@ { "common": { + "add": "Adicionar", "close": "Fechar", "save": "Salvar", "search": "Pesquisar...", @@ -20,13 +21,18 @@ "folder": "pasta", "playlist": "playlist", "next": "Próximo", - "back": "Voltar" + "back": "Voltar", + "more": "Mais", + "select": "Selecionar", + "noResults": "Nenhum resultado" }, "nav": { "library": "Biblioteca", "discovery": "Descobertas", "playlists": "Playlists", - "tags": "Tags" + "tags": "Tags", + "openMenu": "Abrir menu", + "openSettings": "Abrir configurações" }, "settings": { "title": "Configurações", @@ -117,7 +123,12 @@ "cacheSize": "Tamanho do cache", "clearCache": "Limpar cache", "clearCacheConfirmMessage": "Tem certeza de que deseja limpar todo o áudio de visualização em cache?", - "clearCacheWarning": "As faixas visualizadas anteriormente precisarão ser transmitidas novamente de sua fonte na próxima reprodução. Isso pode ser mais lento e alguns fluxos podem não estar mais disponíveis." + "clearCacheWarning": "As faixas visualizadas anteriormente precisarão ser transmitidas novamente de sua fonte na próxima reprodução. Isso pode ser mais lento e alguns fluxos podem não estar mais disponíveis.", + "audioCache": "Áudio", + "artworkCache": "Capa", + "artworkCacheDescription": "A arte do álbum é salva neste dispositivo para que apareça sem conexão.", + "clearArtworkCacheConfirmMessage": "Tem certeza de que deseja limpar toda a arte do álbum em cache?", + "cacheLimit": "Limite de cache" }, "following": { "title": "Seguindo", @@ -179,7 +190,8 @@ "updates": "Atualizações", "checkForUpdates": "Verificar atualizações", "upToDate": "Atualizado", - "updateAvailable": "{version} disponível" + "updateAvailable": "{version} disponível", + "project": "Projeto" } }, "cloudSync": { @@ -200,6 +212,9 @@ "syncNow": "Sincronizar agora", "syncing": "Sincronizando…", "signOut": "Sair", + "signOutConfirmTitle": "Sair?", + "signOutConfirmMessage": "Seus dados locais permanecem neste dispositivo. A sincronização será interrompida até que você faça login novamente.", + "autoSyncHint": "Sua biblioteca Discovery sincroniza automaticamente em todos os seus dispositivos.", "lastSynced": "Última sincronização {time}" }, "devices": { @@ -245,7 +260,26 @@ "volume": "Volume", "tempo": "Tempo", "resetTempo": "Redefinir tempo", - "trackNotFound": "Não foi possível localizar a faixa em reprodução" + "trackNotFound": "Não foi possível localizar a faixa em reprodução", + "expand": "Expandir reprodutor", + "collapse": "Recolher reprodutor", + "seek": "Buscar" + }, + "queue": { + "upNext": "Próximo", + "openQueue": "Próximo", + "playNext": "Reproduzir próximo", + "addToQueue": "Adicionar à fila", + "playingNext": "Reproduzindo próximo", + "addedToQueue": "Adicionado à fila", + "nextInQueue": "Próximo na fila", + "upNextFromContext": "Próximo", + "addedByYou": "Adicionado por você", + "removeFromQueue": "Remover da fila", + "reorder": "Reordenar", + "clearQueue": "Limpar", + "empty": "Nada próximo", + "emptyHint": "As músicas que você adicionar serão reproduzidas a seguir" }, "library": { "searchPlaceholder": "Buscar faixas...", @@ -277,7 +311,18 @@ "releases": "lançamentos", "searchPlaceholder": "Pesquisar lançamentos...", "noReleasesYet": "Nenhum lançamento ainda", + "noResults": "Nenhum lançamento corresponde aos seus filtros", "addReleaseHint": "Pressione {shortcut} para adicionar lançamentos do Bandcamp, Discogs, YouTube e muito mais", + "mobileAddHint": "Adicionar lançamentos do Bandcamp, Discogs, YouTube e muito mais", + "pending": "Pendente", + "offlineQueueNotice": "Você está offline — este lançamento será adicionado quando você se reconectar", + "addToQueue": "Adicionar à fila", + "editRelease": "Editar lançamento", + "goToRelease": "Ir para lançamento", + "sortBy": "Ordenar por", + "selectedCount": "{count, plural, =1 {1 selecionado} other {{count} selecionados}}", + "confirmDeleteTitle": "{count, plural, =1 {Excluir lançamento?} other {Excluir {count} lançamentos?}}", + "confirmDeleteMessage": "Esta ação não pode ser desfeita.", "previewNotAvailable": "Pré-visualização não disponível para esta fonte", "columns": { "artistTitle": "Artista / Título", @@ -289,11 +334,17 @@ }, "fetchingMetadata": "Obtendo metadados...", "refreshMetadata": "Atualizar metadados", + "downloadForOffline": "Baixar para offline", + "downloading": "Baixando...", + "downloadedForOffline": "Baixado", + "removeDownload": "Remover download", + "downloadFailed": "Não foi possível baixar para offline", "fetchError": "Não foi possível obter metadados desta URL", "unsupportedUrl": "Esta URL não é suportada", "tracks": "Faixas", "trackCount": "{count, plural, =1 {1 faixa} other {{count} faixas}}", "openInBrowser": "Abrir no navegador", + "openInApp": "Abrir em {app}", "copyUrl": "Copiar URL", "copiedUrl": "URL copiada para a área de transferência", "like": "Adicionar às curtidas", @@ -312,7 +363,9 @@ "title": "Título", "label": "Gravadora", "releaseDate": "Data de lançamento", - "notes": "Notas" + "notes": "Notas", + "notesPlaceholder": "Adicionar notas…", + "addTags": "Adicionar tags" }, "import": { "title": "Importar para a biblioteca", @@ -423,6 +476,11 @@ "rootNoFolder": "Raiz (sem pasta)", "folderEmpty": "Esta pasta está vazia", "noPlaylistsYet": "Nenhuma playlist ainda", + "noPlaylists": "Nenhuma playlist ainda", + "allPlaylists": "Todas as playlists", + "searchPlaceholder": "Pesquisar playlists…", + "emptyHint": "Crie playlists para organizar suas descobertas", + "detailEmptyHint": "Pressione e mantenha um lançamento em Descobertas para adicioná-lo a uma playlist", "exportToDevice": "Exportar para dispositivo" }, "tags": { @@ -436,7 +494,8 @@ "addTag": "Adicionar tag", "moveToCategory": "Mover para categoria", "noTags": "Nenhuma tag", - "noTagCategoriesYet": "Nenhuma categoria de tag ainda" + "noTagCategoriesYet": "Nenhuma categoria de tag ainda", + "emptyHint": "Crie categorias de tags codificadas por cores para organizar suas descobertas" }, "contextMenu": { "viewInFinder": "Ver no Finder", @@ -701,6 +760,9 @@ "emerald": "Esmeralda" }, "dates": { + "justNow": "Agora mesmo", + "minutesAgo": "{count, plural, =1 {há 1 minuto} other {há {count} minutos}}", + "hoursAgo": "{count, plural, =1 {há 1 hora} other {há {count} horas}}", "today": "Hoje", "yesterday": "Ontem", "daysAgo": "{count, plural, =1 {há 1 dia} other {há {count} dias}}", @@ -891,6 +953,26 @@ "title": "Você está pronto!", "subtitle": "Comece a construir sua coleção." }, - "getStarted": "Começar" + "getStarted": "Começar", + "mobile": { + "welcome": { + "title": "Bem-vindo ao {appName}", + "subtitle": "Descubra música e construa sua coleção — diretamente do seu bolso." + }, + "discover": { + "title": "Adicionar e visualizar lançamentos", + "description": "Cole um link do Bandcamp, SoundCloud e muito mais para salvar um lançamento, depois toque para uma visualização instantânea." + }, + "sync": { + "title": "Sincronizar com seu desktop", + "description": "Opcionalmente faça login para manter sua coleção sincronizada em todos os seus dispositivos. Você sempre pode fazer isso mais tarde." + }, + "appearance": { + "title": "Torne-o seu", + "description": "Escolha um tema e uma cor de destaque. Você pode alterar esses dados a qualquer momento em Configurações." + }, + "skip": "Pular", + "maybeLater": "Talvez mais tarde" + } } } diff --git a/src/lib/i18n/locales/ro.json b/shared/i18n/locales/ro.json similarity index 90% rename from src/lib/i18n/locales/ro.json rename to shared/i18n/locales/ro.json index 41761993..9483ca8a 100644 --- a/src/lib/i18n/locales/ro.json +++ b/shared/i18n/locales/ro.json @@ -1,5 +1,6 @@ { "common": { + "add": "Adaugă", "close": "Închide", "save": "Salvează", "search": "Caută...", @@ -20,13 +21,18 @@ "folder": "folder", "playlist": "playlist", "next": "Următorul", - "back": "Înapoi" + "back": "Înapoi", + "more": "Mai mult", + "select": "Selectează", + "noResults": "Niciun rezultat" }, "nav": { "library": "Bibliotecă", "discovery": "Descoperiri", "playlists": "Playlisturi", - "tags": "Etichete" + "tags": "Etichete", + "openMenu": "Deschideți meniu", + "openSettings": "Deschideți setări" }, "settings": { "title": "Setări", @@ -117,7 +123,12 @@ "cacheSize": "Dimensiune cache", "clearCache": "Goliți Cache", "clearCacheConfirmMessage": "Sunteți sigur că doriți să goliți cache-ul cu tot audioformatul previzualizat?", - "clearCacheWarning": "Melodiile previzualizate anterior vor trebui să fie reîncărcate din sursa lor la următoarea redare. Aceasta poate fi mai lentă și unele fluxuri pot să nu mai fie disponibile." + "clearCacheWarning": "Melodiile previzualizate anterior vor trebui să fie reîncărcate din sursa lor la următoarea redare. Aceasta poate fi mai lentă și unele fluxuri pot să nu mai fie disponibile.", + "audioCache": "Audio", + "artworkCache": "Copertă", + "artworkCacheDescription": "Arta albumului este salvată pe acest dispozitiv, deci se afișează fără conexiune.", + "clearArtworkCacheConfirmMessage": "Sunteți sigur că doriți să goliți cache-ul cu toată arta albumului?", + "cacheLimit": "Limita cache" }, "following": { "title": "Urmărire", @@ -179,7 +190,8 @@ "updates": "Actualizări", "checkForUpdates": "Caută Actualizări", "upToDate": "Actualizat", - "updateAvailable": "{version} disponibil" + "updateAvailable": "{version} disponibil", + "project": "Proiect" } }, "cloudSync": { @@ -200,6 +212,9 @@ "syncNow": "Sincronizați acum", "syncing": "Se sincronizează…", "signOut": "Deconectați-vă", + "signOutConfirmTitle": "Deconectați-vă?", + "signOutConfirmMessage": "Datele locale dvs. rămân pe acest dispozitiv. Sincronizarea se va opri până când vă conectați din nou.", + "autoSyncHint": "Biblioteca Discovery se sincronizează automat pe toate dispozitivele dvs.", "lastSynced": "Ultima sincronizare {time}" }, "devices": { @@ -245,7 +260,26 @@ "volume": "Volum", "tempo": "Tempo", "resetTempo": "Resetează tempo", - "trackNotFound": "Nu s-a putut localiza piesa în curs de redare" + "trackNotFound": "Nu s-a putut localiza piesa în curs de redare", + "expand": "Extindeți playerul", + "collapse": "Restrângeți playerul", + "seek": "Cauta" + }, + "queue": { + "upNext": "Următorul", + "openQueue": "Următorul", + "playNext": "Redează următorul", + "addToQueue": "Adaugă la coadă", + "playingNext": "Redare următorul", + "addedToQueue": "Adăugat la coadă", + "nextInQueue": "Următorul în coadă", + "upNextFromContext": "Următorul", + "addedByYou": "Adăugat de tine", + "removeFromQueue": "Elimină din coadă", + "reorder": "Reordonează", + "clearQueue": "Șterge", + "empty": "Nimic mai departe", + "emptyHint": "Melodiile pe care le adaugi vor fi redate mai departe" }, "library": { "searchPlaceholder": "Caută melodii...", @@ -277,7 +311,18 @@ "releases": "lansări", "searchPlaceholder": "Caută lansări...", "noReleasesYet": "Nicio lansare deocamdată", + "noResults": "Nicio lansare nu corespunde filtrelor dvs.", "addReleaseHint": "Apăsați {shortcut} pentru a adăuga lansări de pe Bandcamp, Discogs, YouTube și altele", + "mobileAddHint": "Adaugă lansări de pe Bandcamp, Discogs, YouTube și altele", + "pending": "În așteptare", + "offlineQueueNotice": "Sunteți deconectat — această lansare va fi adăugată când vă reconectați", + "addToQueue": "Adaugă în coadă", + "editRelease": "Editează Lansare", + "goToRelease": "Mergi la Lansare", + "sortBy": "Sortează după", + "selectedCount": "{count, plural, =1 {1 selectat} other {{count} selectate}}", + "confirmDeleteTitle": "{count, plural, =1 {Șterge lansare?} other {Șterge {count} lansări?}}", + "confirmDeleteMessage": "Aceasta nu poate fi anulată.", "previewNotAvailable": "Previzualizare nu este disponibilă pentru această sursă", "columns": { "artistTitle": "Artist / Titlu", @@ -289,10 +334,16 @@ }, "fetchingMetadata": "Se preiau metadatele...", "refreshMetadata": "Reîncarcă Metadate", + "downloadForOffline": "Descarcă pentru offline", + "downloading": "Se descarcă...", + "downloadedForOffline": "Descărcate", + "removeDownload": "Elimină descărcarea", + "downloadFailed": "Descărcarea pentru offline a eșuat", "fetchError": "Nu s-au putut prelua metadatele din acest URL", "tracks": "Melodii", "trackCount": "{count, plural, =1 {1 melodie} other {{count} melodii}}", "openInBrowser": "Deschide în Browser", + "openInApp": "Deschide în {app}", "copyUrl": "Copiază URL", "copiedUrl": "URL copiat în clipboard", "like": "Adaugă la Apreciate", @@ -311,7 +362,9 @@ "title": "Titlu", "label": "Label", "releaseDate": "Data Lansării", - "notes": "Note" + "notes": "Note", + "notesPlaceholder": "Adaugă note…", + "addTags": "Adaugă etichete" }, "import": { "title": "Importă în Bibliotecă", @@ -422,6 +475,11 @@ "rootNoFolder": "Root (Fără Folder)", "folderEmpty": "Acest folder este gol", "noPlaylistsYet": "Niciun playlist deocamdată", + "noPlaylists": "Niciun playlist deocamdată", + "allPlaylists": "Toate Playlisturile", + "searchPlaceholder": "Caută playlisturi…", + "emptyHint": "Creați playlisturi pentru a vă organiza descoperirile", + "detailEmptyHint": "Apăsați lung pe o lansare în Descoperiri pentru a o adăuga la un playlist", "exportToDevice": "Exportă pe Dispozitiv" }, "tags": { @@ -435,7 +493,8 @@ "addTag": "Adaugă Etichetă", "moveToCategory": "Mută în Categorie", "noTags": "Nicio etichetă", - "noTagCategoriesYet": "Nicio categorie de etichete deocamdată" + "noTagCategoriesYet": "Nicio categorie de etichete deocamdată", + "emptyHint": "Creați categorii de etichete codificate prin culoare pentru a vă organiza descoperirile" }, "contextMenu": { "viewInFinder": "Arată în Finder", @@ -701,6 +760,9 @@ "emerald": "Smarald" }, "dates": { + "justNow": "Chiar acum", + "minutesAgo": "{count, plural, =1 {acum 1 minut} other {acum {count} minute}}", + "hoursAgo": "{count, plural, =1 {acum 1 oră} other {acum {count} ore}}", "today": "Azi", "yesterday": "Ieri", "daysAgo": "{count, plural, =1 {acum 1 zi} other {acum {count} zile}}", @@ -888,6 +950,26 @@ "title": "Totul este gata!", "subtitle": "Începeți să construiți colecția dvs." }, - "getStarted": "Pornire" + "getStarted": "Pornire", + "mobile": { + "welcome": { + "title": "Bun venit în {appName}", + "subtitle": "Descoperiți muzică și construiți-vă colecția — direct din buzunar." + }, + "discover": { + "title": "Adăugați și previzualizați lansări", + "description": "Lipiți un link de la Bandcamp, SoundCloud și altele pentru a salva o lansare, apoi atingeți pentru o previzualizare instantanee." + }, + "sync": { + "title": "Sincronizați cu desktopul dvs.", + "description": "Conectați-vă opțional pentru a menține colecția sincronizată pe toate dispozitivele. Puteți face asta oricând mai târziu." + }, + "appearance": { + "title": "Faceți-l al vostru", + "description": "Alegeți o temă și o culoare de accent. Puteți schimba acestea oricând în Setări." + }, + "skip": "Omiteți", + "maybeLater": "Poate mai târziu" + } } } \ No newline at end of file diff --git a/src/lib/i18n/locales/sv.json b/shared/i18n/locales/sv.json similarity index 89% rename from src/lib/i18n/locales/sv.json rename to shared/i18n/locales/sv.json index ecd5d3ef..77908b62 100644 --- a/src/lib/i18n/locales/sv.json +++ b/shared/i18n/locales/sv.json @@ -1,5 +1,6 @@ { "common": { + "add": "Lägg till", "close": "Stäng", "save": "Spara", "search": "Sök...", @@ -20,13 +21,18 @@ "folder": "mapp", "playlist": "spellista", "next": "Nästa", - "back": "Tillbaka" + "back": "Tillbaka", + "more": "Mer", + "select": "Välj", + "noResults": "Inga resultat" }, "nav": { "library": "Bibliotek", "discovery": "Upptäckter", "playlists": "Spellistor", - "tags": "Taggar" + "tags": "Taggar", + "openMenu": "Öppna meny", + "openSettings": "Öppna inställningar" }, "settings": { "title": "Inställningar", @@ -117,7 +123,12 @@ "cacheSize": "Cachestorlek", "clearCache": "Rensa cache", "clearCacheConfirmMessage": "Är du säker på att du vill rensa all cachelagrat förhandsgranskningsljud?", - "clearCacheWarning": "Tidigare förhandsgranskat spår måste strömmas igen från sin källa vid nästa uppspelning. Detta kan vara långsammare och vissa strömmar kanske inte längre är tillgängliga." + "clearCacheWarning": "Tidigare förhandsgranskat spår måste strömmas igen från sin källa vid nästa uppspelning. Detta kan vara långsammare och vissa strömmar kanske inte längre är tillgängliga.", + "audioCache": "Ljud", + "artworkCache": "Albumomslag", + "artworkCacheDescription": "Albumomslag sparas på denna enhet så att de visas utan anslutning.", + "clearArtworkCacheConfirmMessage": "Är du säker på att du vill rensa alla cachelagrade albumomslag?", + "cacheLimit": "Cachegräns" }, "following": { "title": "Följar", @@ -179,7 +190,8 @@ "updates": "Uppdateringar", "checkForUpdates": "Sök efter uppdateringar", "upToDate": "Uppdaterad", - "updateAvailable": "{version} tillgänglig" + "updateAvailable": "{version} tillgänglig", + "project": "Projekt" } }, "cloudSync": { @@ -200,6 +212,9 @@ "syncNow": "Synkronisera nu", "syncing": "Synkroniserar…", "signOut": "Logga ut", + "signOutConfirmTitle": "Logga ut?", + "signOutConfirmMessage": "Dina lokala data förblir på den här enheten. Synkroniseringen stoppas tills du loggar in igen.", + "autoSyncHint": "Ditt Discovery-bibliotek synkroniseras automatiskt på alla dina enheter.", "lastSynced": "Senast synkroniserad {time}" }, "devices": { @@ -245,7 +260,26 @@ "volume": "Volym", "tempo": "Tempo", "resetTempo": "Återställ tempo", - "trackNotFound": "Kunde inte hitta det spelande spåret" + "trackNotFound": "Kunde inte hitta det spelande spåret", + "expand": "Expandera spelare", + "collapse": "Fäll ihop spelare", + "seek": "Sök" + }, + "queue": { + "upNext": "Upp nästa", + "openQueue": "Nästa", + "playNext": "Spela nästa", + "addToQueue": "Lägg till i kö", + "playingNext": "Spelar nästa", + "addedToQueue": "Tillagt i kö", + "nextInQueue": "Nästa i kö", + "upNextFromContext": "Upp nästa", + "addedByYou": "Tillagt av dig", + "removeFromQueue": "Ta bort från kö", + "reorder": "Sortera om", + "clearQueue": "Rensa", + "empty": "Inget upp nästa", + "emptyHint": "Låtar du lägger till kommer spelas nästa" }, "library": { "searchPlaceholder": "Sök spår...", @@ -277,7 +311,18 @@ "releases": "utgåvor", "searchPlaceholder": "Sök släpp...", "noReleasesYet": "Inga utgåvor än", + "noResults": "Inga utgåvor matchar dina filter", "addReleaseHint": "Tryck {shortcut} för att lägga till utgåvor från Bandcamp, Discogs, YouTube och mer", + "mobileAddHint": "Lägg till utgåvor från Bandcamp, Discogs, YouTube och mer", + "pending": "Väntande", + "offlineQueueNotice": "Du är offline — denna utgåva kommer att läggas till när du ansluter igen", + "addToQueue": "Lägg till i kö", + "editRelease": "Redigera utgåva", + "goToRelease": "Gå till utgåva", + "sortBy": "Sortera efter", + "selectedCount": "{count, plural, =1 {1 vald} other {{count} valda}}", + "confirmDeleteTitle": "{count, plural, =1 {Ta bort utgåva?} other {Ta bort {count} utgåvor?}}", + "confirmDeleteMessage": "Detta kan inte ångras.", "previewNotAvailable": "Förhandsvisning inte tillgänglig för denna källa", "columns": { "artistTitle": "Artist / Titel", @@ -289,11 +334,17 @@ }, "fetchingMetadata": "Hämtar metadata...", "refreshMetadata": "Uppdatera metadata", + "downloadForOffline": "Ladda ned för offline", + "downloading": "Laddar ned...", + "downloadedForOffline": "Nedladdad", + "removeDownload": "Ta bort nedladdning", + "downloadFailed": "Kunde inte ladda ned för offline", "fetchError": "Kunde inte hämta metadata från denna URL", "unsupportedUrl": "Denna URL stöds inte", "tracks": "Spår", "trackCount": "{count, plural, =1 {1 spår} other {{count} spår}}", "openInBrowser": "Öppna i webbläsare", + "openInApp": "Öppna i {app}", "copyUrl": "Kopiera URL", "copiedUrl": "URL kopierad till urklipp", "like": "Lägg till i gillat", @@ -312,7 +363,9 @@ "title": "Titel", "label": "Skivbolag", "releaseDate": "Releasedatum", - "notes": "Anteckningar" + "notes": "Anteckningar", + "notesPlaceholder": "Lägg till anteckningar…", + "addTags": "Lägg till taggar" }, "import": { "title": "Importera till bibliotek", @@ -423,6 +476,11 @@ "rootNoFolder": "Rot (ingen mapp)", "folderEmpty": "Denna mapp är tom", "noPlaylistsYet": "Inga spellistor än", + "noPlaylists": "Inga spellistor än", + "allPlaylists": "Alla spellistor", + "searchPlaceholder": "Sök spellistor…", + "emptyHint": "Skapa spellistor för att organisera dina upptäckter", + "detailEmptyHint": "Håll ned en utgåva i Upptäckter för att lägga till den i en spellista", "exportToDevice": "Exportera till enhet" }, "tags": { @@ -436,7 +494,8 @@ "addTag": "Lägg till tagg", "moveToCategory": "Flytta till kategori", "noTags": "Inga taggar", - "noTagCategoriesYet": "Inga taggkategorier än" + "noTagCategoriesYet": "Inga taggkategorier än", + "emptyHint": "Skapa färgkodade taggkategorier för att organisera dina upptäckter" }, "contextMenu": { "viewInFinder": "Visa i Finder", @@ -701,6 +760,9 @@ "emerald": "Smaragd" }, "dates": { + "justNow": "Just nu", + "minutesAgo": "{count, plural, =1 {för 1 minut sedan} other {för {count} minuter sedan}}", + "hoursAgo": "{count, plural, =1 {för 1 timme sedan} other {för {count} timmar sedan}}", "today": "Idag", "yesterday": "Igår", "daysAgo": "{count, plural, =1 {för 1 dag sedan} other {för {count} dagar sedan}}", @@ -891,6 +953,26 @@ "title": "Du är redo!", "subtitle": "Börja bygga din samling." }, - "getStarted": "Kom igång" + "getStarted": "Kom igång", + "mobile": { + "welcome": { + "title": "Välkommen till {appName}", + "subtitle": "Upptäck musik och bygg din samling — direkt från din ficka." + }, + "discover": { + "title": "Lägg till och förhandsgranska utgåvor", + "description": "Klistra in en länk från Bandcamp, SoundCloud och mer för att spara en utgåva, sedan tryck för att förhandsgranska direkt." + }, + "sync": { + "title": "Synkronisera med din skrivbord", + "description": "Logga in valfritt för att hålla din samling synkroniserad på alla dina enheter. Du kan alltid göra det senare." + }, + "appearance": { + "title": "Gör det ditt", + "description": "Välj ett tema och en accentfärg. Du kan ändra dessa när som helst i Inställningar." + }, + "skip": "Hoppa över", + "maybeLater": "Kanske senare" + } } } diff --git a/src/lib/i18n/locales/tr.json b/shared/i18n/locales/tr.json similarity index 90% rename from src/lib/i18n/locales/tr.json rename to shared/i18n/locales/tr.json index 6b380715..487050d1 100644 --- a/src/lib/i18n/locales/tr.json +++ b/shared/i18n/locales/tr.json @@ -1,5 +1,6 @@ { "common": { + "add": "Ekle", "close": "Kapat", "save": "Kaydet", "search": "Ara...", @@ -20,13 +21,18 @@ "folder": "klasör", "playlist": "çalma listesi", "next": "İleri", - "back": "Geri" + "back": "Geri", + "more": "Daha fazla", + "select": "Seç", + "noResults": "Sonuç bulunamadı" }, "nav": { "library": "Kütüphane", "discovery": "Keşfet", "playlists": "Çalma Listeleri", - "tags": "Etiketler" + "tags": "Etiketler", + "openMenu": "Menüyü aç", + "openSettings": "Ayarları aç" }, "settings": { "title": "Ayarlar", @@ -117,7 +123,12 @@ "cacheSize": "Önbellek boyutu", "clearCache": "Önbelleği Temizle", "clearCacheConfirmMessage": "Tüm önbelleğe alınan önizleme sesini temizlemek istediğinizden emin misiniz?", - "clearCacheWarning": "Önceden önizlenmiş şarkılar bir sonraki oynatmada kaynağından yeniden akışla getirilmesi gerekecektir. Bu daha yavaş olabilir ve bazı akışlar artık mevcut olmayabilir." + "clearCacheWarning": "Önceden önizlenmiş şarkılar bir sonraki oynatmada kaynağından yeniden akışla getirilmesi gerekecektir. Bu daha yavaş olabilir ve bazı akışlar artık mevcut olmayabilir.", + "audioCache": "Ses", + "artworkCache": "Kapak Sanatı", + "artworkCacheDescription": "Albüm kapak sanatı bu cihaza kaydedilir, böylece bağlantı olmadan görüntülenebilir.", + "clearArtworkCacheConfirmMessage": "Tüm önbelleğe alınan albüm kapak sanatını temizlemek istediğinizden emin misiniz?", + "cacheLimit": "Önbellek sınırı" }, "following": { "title": "İzleniyor", @@ -179,7 +190,8 @@ "updates": "Güncellemeler", "checkForUpdates": "Güncellemeleri Denetle", "upToDate": "Güncel", - "updateAvailable": "{version} mevcut" + "updateAvailable": "{version} mevcut", + "project": "Proje" } }, "cloudSync": { @@ -200,6 +212,9 @@ "syncNow": "Şimdi senkronize et", "syncing": "Senkronize ediliyor…", "signOut": "Oturumu Kapat", + "signOutConfirmTitle": "Oturumu kapat?", + "signOutConfirmMessage": "Yerel verileriniz bu cihazda kalır. Yeniden oturum açana kadar senkronizasyon durur.", + "autoSyncHint": "Discovery kütüphaneniz tüm cihazlarınızda otomatik olarak senkronize olur.", "lastSynced": "Son senkronizasyon {time}" }, "devices": { @@ -245,7 +260,26 @@ "volume": "Ses Düzeyi", "tempo": "Tempo", "resetTempo": "Tempoyu Sıfırla", - "trackNotFound": "Çalan parça bulunamadı" + "trackNotFound": "Çalan parça bulunamadı", + "expand": "Oynatıcıyı Genişlet", + "collapse": "Oynatıcıyı Daralt", + "seek": "Ara" + }, + "queue": { + "upNext": "Sırada", + "openQueue": "Sırada", + "playNext": "Sonrakini oynat", + "addToQueue": "Sıraya ekle", + "playingNext": "Sonraki oynatılıyor", + "addedToQueue": "Sıraya eklendi", + "nextInQueue": "Sırada sonraki", + "upNextFromContext": "Sırada", + "addedByYou": "Sen tarafından eklendi", + "removeFromQueue": "Sıradan kaldır", + "reorder": "Yeniden sırala", + "clearQueue": "Temizle", + "empty": "Sırada hiçbir şey yok", + "emptyHint": "Eklediğin şarkılar sonra oynatılacak" }, "library": { "searchPlaceholder": "Şarkıları ara...", @@ -277,7 +311,18 @@ "releases": "yayın", "searchPlaceholder": "Yayınları ara...", "noReleasesYet": "Henüz yayın yok", + "noResults": "Filtrelerinizle eşleşen yayın yok", "addReleaseHint": "{shortcut} tuşuna basarak Bandcamp, Discogs, YouTube ve daha fazlasından yayın ekleyin", + "mobileAddHint": "Bandcamp, Discogs, YouTube ve daha fazlasından yayın ekleyin", + "pending": "Bekleniyor", + "offlineQueueNotice": "Çevrimdışısınız — yeniden bağlandığınızda bu yayın eklenecektir", + "addToQueue": "Kuyruğa Ekle", + "editRelease": "Yayını Düzenle", + "goToRelease": "Yayına Git", + "sortBy": "Sırala", + "selectedCount": "{count, plural, =1 {1 seçildi} other {{count} seçildi}}", + "confirmDeleteTitle": "{count, plural, =1 {Yayını sil?} other {{count} yayını sil?}}", + "confirmDeleteMessage": "Bu işlem geri alınamaz.", "previewNotAvailable": "Bu kaynak için önizleme mevcut değil", "columns": { "artistTitle": "Sanatçı / Başlık", @@ -289,10 +334,16 @@ }, "fetchingMetadata": "Meta veriler indiriliyor...", "refreshMetadata": "Meta Verileri Yenile", + "downloadForOffline": "Çevrimdışı için İndir", + "downloading": "İndiriliyor...", + "downloadedForOffline": "İndirildi", + "removeDownload": "İndirmeyi Kaldır", + "downloadFailed": "Çevrimdışı için indirilemedi", "fetchError": "Bu URL'den meta veriler indirilemedi", "tracks": "Şarkılar", "trackCount": "{count, plural, =1 {1 şarkı} other {{count} şarkı}}", "openInBrowser": "Tarayıcıda Aç", + "openInApp": "{app}'da Aç", "copyUrl": "URL'yi Kopyala", "copiedUrl": "URL panoya kopyalandı", "like": "Beğenilenlere Ekle", @@ -311,7 +362,9 @@ "title": "Başlık", "label": "Etiket", "releaseDate": "Yayın Tarihi", - "notes": "Notlar" + "notes": "Notlar", + "notesPlaceholder": "Not ekle…", + "addTags": "Etiket ekle" }, "import": { "title": "Kütüphaneye İçe Aktar", @@ -422,6 +475,11 @@ "rootNoFolder": "Kök (Klasör Yok)", "folderEmpty": "Bu klasör boş", "noPlaylistsYet": "Henüz çalma listesi yok", + "noPlaylists": "Henüz çalma listesi yok", + "allPlaylists": "Tüm Çalma Listeleri", + "searchPlaceholder": "Çalma listelerinde ara…", + "emptyHint": "Keşiflerinizi düzenlemek için çalma listeleri oluşturun", + "detailEmptyHint": "Çalma listesine eklemek için Keşfet'te bir yayına uzun basın", "exportToDevice": "Cihaza Dışa Aktar" }, "tags": { @@ -435,7 +493,8 @@ "addTag": "Etiket Ekle", "moveToCategory": "Kategoriye Taşı", "noTags": "Etiket yok", - "noTagCategoriesYet": "Henüz etiket kategorisi yok" + "noTagCategoriesYet": "Henüz etiket kategorisi yok", + "emptyHint": "Keşiflerinizi düzenlemek için renkle kodlanmış etiket kategorileri oluşturun" }, "contextMenu": { "viewInFinder": "Finder'da Göster", @@ -701,6 +760,9 @@ "emerald": "Zümrüt" }, "dates": { + "justNow": "Biraz önce", + "minutesAgo": "{count, plural, =1 {1 dakika önce} other {{count} dakika önce}}", + "hoursAgo": "{count, plural, =1 {1 saat önce} other {{count} saat önce}}", "today": "Bugün", "yesterday": "Dün", "daysAgo": "{count, plural, =1 {1 gün önce} other {{count} gün önce}}", @@ -888,6 +950,26 @@ "title": "Hepsi hazır!", "subtitle": "Koleksiyonunuzu oluşturmaya başlayın." }, - "getStarted": "Başla" + "getStarted": "Başla", + "mobile": { + "welcome": { + "title": "{appName} Uygulamasına Hoş Geldiniz", + "subtitle": "Müzik keşfedin ve koleksiyonunuzu oluşturun — doğrudan cebinizden." + }, + "discover": { + "title": "Yayınları Ekle ve Önizle", + "description": "Bandcamp, SoundCloud ve daha fazlasından bir bağlantı yapıştırarak bir yayını kaydedin, ardından anında önizleme için dokunun." + }, + "sync": { + "title": "Masaüstü ile Senkronize Edin", + "description": "İsteğe bağlı olarak oturum açın ve koleksiyonunuzu tüm cihazlarda senkronize tutun. Bunu her zaman daha sonra yapabilirsiniz." + }, + "appearance": { + "title": "Bunu Kendin Yap", + "description": "Bir tema ve vurgu rengi seçin. Bunları istediğiniz zaman Ayarlar'da değiştirebilirsiniz." + }, + "skip": "Atla", + "maybeLater": "Belki daha sonra" + } } } diff --git a/src/lib/i18n/locales/uk.json b/shared/i18n/locales/uk.json similarity index 90% rename from src/lib/i18n/locales/uk.json rename to shared/i18n/locales/uk.json index 8498824f..f9ed509e 100644 --- a/src/lib/i18n/locales/uk.json +++ b/shared/i18n/locales/uk.json @@ -1,5 +1,6 @@ { "common": { + "add": "Додати", "close": "Закрити", "save": "Зберегти", "search": "Пошук...", @@ -20,13 +21,18 @@ "folder": "папка", "playlist": "плейлист", "next": "Далі", - "back": "Назад" + "back": "Назад", + "more": "Ще", + "select": "Вибрати", + "noResults": "Результатів не знайдено" }, "nav": { "library": "Бібліотека", "discovery": "Пошук", "playlists": "Плейлисти", - "tags": "Теги" + "tags": "Теги", + "openMenu": "Відкрити меню", + "openSettings": "Відкрити параметри" }, "settings": { "title": "Параметри", @@ -117,7 +123,12 @@ "cacheSize": "Розмір кешу", "clearCache": "Очистити кеш", "clearCacheConfirmMessage": "Ви впевнені, що хочете очистити весь кешований аудіо попередній перегляд?", - "clearCacheWarning": "Попередньо переглянуті доріжки потребуватимуть повторного потокового передавання з їхнього джерела при наступному програванні. Це може бути повільнішим, і деякі потоки можуть бути недоступними." + "clearCacheWarning": "Попередньо переглянуті доріжки потребуватимуть повторного потокового передавання з їхнього джерела при наступному програванні. Це може бути повільнішим, і деякі потоки можуть бути недоступними.", + "audioCache": "Аудіо", + "artworkCache": "Обкладинка", + "artworkCacheDescription": "Обкладинки альбомів зберігаються на цьому пристрої, тому вони відображаються без з'єднання.", + "clearArtworkCacheConfirmMessage": "Ви впевнені, що хочете очистити весь кешований обкладинки альбому?", + "cacheLimit": "Межа кешу" }, "following": { "title": "Ми стежимо", @@ -179,7 +190,8 @@ "updates": "Оновлення", "checkForUpdates": "Перевірити наявність оновлень", "upToDate": "Актуально", - "updateAvailable": "Доступна версія {version}" + "updateAvailable": "Доступна версія {version}", + "project": "Проект" } }, "cloudSync": { @@ -200,6 +212,9 @@ "syncNow": "Синхронізувати зараз", "syncing": "Синхронізація…", "signOut": "Вийти", + "signOutConfirmTitle": "Вийти?", + "signOutConfirmMessage": "Ваші локальні дані залишаються на цьому пристрої. Синхронізація зупиниться до повторного входу.", + "autoSyncHint": "Ваша бібліотека Discovery автоматично синхронізується на всіх ваших пристроях.", "lastSynced": "Остання синхронізація {time}" }, "devices": { @@ -245,7 +260,26 @@ "volume": "Гучність", "tempo": "Темп", "resetTempo": "Скинути темп", - "trackNotFound": "Не вдалося знайти відтворюваний трек" + "trackNotFound": "Не вдалося знайти відтворюваний трек", + "expand": "Розширити програвач", + "collapse": "Згорнути програвач", + "seek": "Пошук" + }, + "queue": { + "upNext": "Далі", + "openQueue": "Далі", + "playNext": "Програти далі", + "addToQueue": "Додати в чергу", + "playingNext": "Програється далі", + "addedToQueue": "Додано в чергу", + "nextInQueue": "Далі в черзі", + "upNextFromContext": "Далі", + "addedByYou": "Додано вами", + "removeFromQueue": "Видалити з черги", + "reorder": "Змінити порядок", + "clearQueue": "Очистити", + "empty": "Далі нічого", + "emptyHint": "Пісні, які ви додаєте, будуть програватися далі" }, "library": { "searchPlaceholder": "Пошук доріжок...", @@ -277,7 +311,18 @@ "releases": "видання", "searchPlaceholder": "Пошук видань...", "noReleasesYet": "Видань ще немає", + "noResults": "Жодне видання не відповідає вашим фільтрам", "addReleaseHint": "Натисніть {shortcut}, щоб додати видання з Bandcamp, Discogs, YouTube та інших", + "mobileAddHint": "Додайте видання з Bandcamp, Discogs, YouTube та інших", + "pending": "Очікування", + "offlineQueueNotice": "Ви в режимі офлайн — це видання буде додано, коли ви знову підключитесь", + "addToQueue": "Додати в чергу", + "editRelease": "Редагувати видання", + "goToRelease": "Перейти до видання", + "sortBy": "Сортувати за", + "selectedCount": "{count, plural, =1 {1 вибрано} other {{count} вибрано}}", + "confirmDeleteTitle": "{count, plural, =1 {Видалити видання?} other {Видалити {count} видання?}}", + "confirmDeleteMessage": "Це неможливо скасувати.", "previewNotAvailable": "Попередній перегляд недоступний для цього джерела", "columns": { "artistTitle": "Виконавець / Назва", @@ -289,10 +334,16 @@ }, "fetchingMetadata": "Завантаження метаданих...", "refreshMetadata": "Оновити метаданні", + "downloadForOffline": "Завантажити для офлайну", + "downloading": "Завантаження...", + "downloadedForOffline": "Завантажено", + "removeDownload": "Видалити завантаження", + "downloadFailed": "Не вдалося завантажити для офлайну", "fetchError": "Не вдалося завантажити метаданні з цієї URL", "tracks": "Доріжки", "trackCount": "{count, plural, =1 {1 доріжка} other {{count} доріжок}}", "openInBrowser": "Відкрити в браузері", + "openInApp": "Відкрити в {app}", "copyUrl": "Копіювати URL", "copiedUrl": "URL скопійовано до буфера обміну", "like": "Додати до вподобаних", @@ -311,7 +362,9 @@ "title": "Назва", "label": "Лейбл", "releaseDate": "Дата випуску", - "notes": "Примітки" + "notes": "Примітки", + "notesPlaceholder": "Додати примітки…", + "addTags": "Додати теги" }, "import": { "title": "Імпортувати до бібліотеки", @@ -422,6 +475,11 @@ "rootNoFolder": "Корінь (без папки)", "folderEmpty": "Ця папка порожня", "noPlaylistsYet": "Плейлистів ще немає", + "noPlaylists": "Плейлистів ще немає", + "allPlaylists": "Усі плейлисти", + "searchPlaceholder": "Пошук плейлистів…", + "emptyHint": "Створюйте плейлисти для організації своїх пошуків", + "detailEmptyHint": "Утримуйте видання в Пошуку, щоб додати його до плейлиста", "exportToDevice": "Експортувати на пристрій" }, "tags": { @@ -435,7 +493,8 @@ "addTag": "Додати тег", "moveToCategory": "Перемістити до категорії", "noTags": "Немає тегів", - "noTagCategoriesYet": "Категорій тегів ще немає" + "noTagCategoriesYet": "Категорій тегів ще немає", + "emptyHint": "Створюйте категорії тегів з кодуванням за кольором для організації своїх пошуків" }, "contextMenu": { "viewInFinder": "Показати у Finder", @@ -701,6 +760,9 @@ "emerald": "Смарагдовий" }, "dates": { + "justNow": "Щойно", + "minutesAgo": "{count, plural, =1 {1 хвилину тому} other {{count} хвилин тому}}", + "hoursAgo": "{count, plural, =1 {1 годину тому} other {{count} годин тому}}", "today": "Сьогодні", "yesterday": "Вчора", "daysAgo": "{count, plural, =1 {1 день тому} other {{count} днів тому}}", @@ -888,6 +950,26 @@ "title": "Все готово!", "subtitle": "Почніть збирати свою колекцію." }, - "getStarted": "Почнемо" + "getStarted": "Почнемо", + "mobile": { + "welcome": { + "title": "Ласкаво просимо у {appName}", + "subtitle": "Відкривайте музику та створюйте свою колекцію — прямо з кишені." + }, + "discover": { + "title": "Додайте та переглядайте випуски", + "description": "Вставте посилання з Bandcamp, SoundCloud та інших джерел, щоб зберегти випуск, а потім торкніться для миттєвого перегляду." + }, + "sync": { + "title": "Синхронізуйте з комп'ютером", + "description": "За бажанням увійдіть, щоб синхронізувати колекцію на всіх пристроях. Ви завжди можете зробити це пізніше." + }, + "appearance": { + "title": "Зробіть це своїм", + "description": "Виберіть тему та колір акценту. Ви можете змінювати ці параметри будь-коли в Параметрах." + }, + "skip": "Пропустити", + "maybeLater": "Можливо, пізніше" + } } } \ No newline at end of file diff --git a/src/lib/i18n/locales/zh.json b/shared/i18n/locales/zh.json similarity index 89% rename from src/lib/i18n/locales/zh.json rename to shared/i18n/locales/zh.json index ae9ce9f4..df663cc1 100644 --- a/src/lib/i18n/locales/zh.json +++ b/shared/i18n/locales/zh.json @@ -1,5 +1,6 @@ { "common": { + "add": "添加", "close": "关闭", "save": "保存", "search": "搜索...", @@ -20,13 +21,18 @@ "folder": "文件夹", "playlist": "播放列表", "next": "下一个", - "back": "返回" + "back": "返回", + "more": "更多", + "select": "选择", + "noResults": "无结果" }, "nav": { "library": "曲库", "discovery": "发现", "playlists": "播放列表", - "tags": "标签" + "tags": "标签", + "openMenu": "打开菜单", + "openSettings": "打开设置" }, "settings": { "title": "设置", @@ -117,7 +123,12 @@ "cacheSize": "缓存大小", "clearCache": "清除缓存", "clearCacheConfirmMessage": "确定要清除所有缓存的预览音频吗?", - "clearCacheWarning": "之前预览的曲目需要在下次播放时从其来源重新流式传输。这可能会更慢,某些流可能不再可用。" + "clearCacheWarning": "之前预览的曲目需要在下次播放时从其来源重新流式传输。这可能会更慢,某些流可能不再可用。", + "audioCache": "音频", + "artworkCache": "专辑封面", + "artworkCacheDescription": "专辑封面保存在此设备上,无需连接即可显示。", + "clearArtworkCacheConfirmMessage": "确定要清除所有缓存的专辑封面吗?", + "cacheLimit": "缓存限制" }, "following": { "title": "关注中", @@ -179,7 +190,8 @@ "updates": "更新", "checkForUpdates": "检查更新", "upToDate": "已是最新版本", - "updateAvailable": "{version} 可用" + "updateAvailable": "{version} 可用", + "project": "项目" } }, "cloudSync": { @@ -200,6 +212,9 @@ "syncNow": "立即同步", "syncing": "正在同步…", "signOut": "登出", + "signOutConfirmTitle": "确认登出?", + "signOutConfirmMessage": "您的本地数据将保留在此设备上。在您重新登录之前,同步将停止。", + "autoSyncHint": "您的 Discovery 曲库会自动在您的所有设备间同步。", "lastSynced": "最后同步于 {time}" }, "devices": { @@ -245,7 +260,26 @@ "volume": "音量", "tempo": "速度", "resetTempo": "重置速度", - "trackNotFound": "无法找到正在播放的曲目" + "trackNotFound": "无法找到正在播放的曲目", + "expand": "展开播放器", + "collapse": "收起播放器", + "seek": "快进" + }, + "queue": { + "upNext": "接下来", + "openQueue": "接下来", + "playNext": "播放下一首", + "addToQueue": "添加到队列", + "playingNext": "接下来播放", + "addedToQueue": "已添加到队列", + "nextInQueue": "队列中的下一首", + "upNextFromContext": "接下来", + "addedByYou": "由您添加", + "removeFromQueue": "从队列中移除", + "reorder": "重新排列", + "clearQueue": "清除", + "empty": "接下来没有内容", + "emptyHint": "您添加的歌曲将接下来播放" }, "library": { "searchPlaceholder": "搜索曲目...", @@ -277,7 +311,18 @@ "releases": "发行", "searchPlaceholder": "搜索发行...", "noReleasesYet": "暂无发行", + "noResults": "没有发行符合您的筛选条件", "addReleaseHint": "按下 {shortcut} 从 Bandcamp、Discogs、YouTube 等添加发行", + "mobileAddHint": "从 Bandcamp、Discogs、YouTube 等添加发行", + "pending": "待处理", + "offlineQueueNotice": "您处于离线状态 — 重新连接后将添加此发行", + "addToQueue": "添加到队列", + "editRelease": "编辑发行", + "goToRelease": "前往发行", + "sortBy": "排序方式", + "selectedCount": "{count, plural, =1 {已选择 1 个} other {已选择 {count} 个}}", + "confirmDeleteTitle": "{count, plural, =1 {删除发行?} other {删除 {count} 个发行?}}", + "confirmDeleteMessage": "此操作无法撤销。", "previewNotAvailable": "此来源不支持预览", "columns": { "artistTitle": "艺术家 / 标题", @@ -289,11 +334,17 @@ }, "fetchingMetadata": "正在获取元数据...", "refreshMetadata": "刷新元数据", + "downloadForOffline": "下载以供离线使用", + "downloading": "下载中...", + "downloadedForOffline": "已下载", + "removeDownload": "删除下载", + "downloadFailed": "无法下载以供离线使用", "fetchError": "无法从此URL获取元数据", "unsupportedUrl": "不支持此URL", "tracks": "曲目", "trackCount": "{count, plural, =1 {1 首曲目} other {{count} 首曲目}}", "openInBrowser": "在浏览器中打开", + "openInApp": "在 {app} 中打开", "copyUrl": "复制URL", "copiedUrl": "已复制URL到剪贴板", "like": "添加到已赞", @@ -312,7 +363,9 @@ "title": "标题", "label": "厂牌", "releaseDate": "发行日期", - "notes": "备注" + "notes": "备注", + "notesPlaceholder": "添加备注…", + "addTags": "添加标签" }, "import": { "title": "导入到音乐库", @@ -423,6 +476,11 @@ "rootNoFolder": "根目录(无文件夹)", "folderEmpty": "此文件夹为空", "noPlaylistsYet": "暂无播放列表", + "noPlaylists": "暂无播放列表", + "allPlaylists": "所有播放列表", + "searchPlaceholder": "搜索播放列表…", + "emptyHint": "创建播放列表来整理你的发现", + "detailEmptyHint": "在发现中长按发行来将其添加到播放列表", "exportToDevice": "导出到设备" }, "tags": { @@ -436,7 +494,8 @@ "addTag": "添加标签", "moveToCategory": "移动到分类", "noTags": "暂无标签", - "noTagCategoriesYet": "暂无标签分类" + "noTagCategoriesYet": "暂无标签分类", + "emptyHint": "创建带颜色编码的标签分类来整理你的发现" }, "contextMenu": { "viewInFinder": "在访达中显示", @@ -701,6 +760,9 @@ "emerald": "翠绿色" }, "dates": { + "justNow": "刚刚", + "minutesAgo": "{count, plural, =1 {1 分钟前} other {{count} 分钟前}}", + "hoursAgo": "{count, plural, =1 {1 小时前} other {{count} 小时前}}", "today": "今天", "yesterday": "昨天", "daysAgo": "{count, plural, =1 {1 天前} other {{count} 天前}}", @@ -891,6 +953,26 @@ "title": "一切准备就绪!", "subtitle": "开始构建您的音乐收藏。" }, - "getStarted": "开始" + "getStarted": "开始", + "mobile": { + "welcome": { + "title": "欢迎来到 {appName}", + "subtitle": "发现音乐并构建您的收藏 — 直接在您的口袋中。" + }, + "discover": { + "title": "添加和预览发行版", + "description": "粘贴 Bandcamp、SoundCloud 等的链接来保存发行版,然后点击以获得即时预览。" + }, + "sync": { + "title": "与您的桌面同步", + "description": "可选地登录以在所有设备之间保持收藏同步。您可以稍后随时执行此操作。" + }, + "appearance": { + "title": "自定义您的外观", + "description": "选择一个主题和强调色。您可以随时在设置中更改它们。" + }, + "skip": "跳过", + "maybeLater": "也许稍后" + } } } diff --git a/shared/services/nativePreviewPlayer.ts b/shared/services/nativePreviewPlayer.ts new file mode 100644 index 00000000..c5237b13 --- /dev/null +++ b/shared/services/nativePreviewPlayer.ts @@ -0,0 +1,118 @@ +/** + * iOS native preview playback client (#54). + * + * The iOS counterpart to `previewPlayer.ts`: instead of an HTML5 `